[object Object]

← back to Wallco Ai

marketplace YOLO loop · overnight milestone 2 — parallel-tab landings

faa24dd1247e81bad0e2454af7c7e90f8239b91d · 2026-05-13 14:52:13 -0700 · SteveStudio2

Seven parallel iTerm Claude tabs grinding overnight. Net 2,556 LOC of
additive marketplace progress (Gemini-generated PNGs gitignored):

- ai.js Gemini wire (tab #1) — gemini-2.5-flash-image recolor with 6
  colorway directions (Warm Clay/Moss & Linen/Ink Blue/Rose Plaster/
  Gold Smoke/Pearl Frost), PIL palette extract, cost-tracker hook,
  persists into mp_pattern_colorways
- orders.js (tab #3) — sample/wallpaper Shopify draft-order pipeline +
  fulfilled-webhook → mp_order_items + mp_commission_ledger
- patterns.html + /api/marketplace/search (tab #4) — FTS query +
  style/room/color/commercial filters, debounced live search persisted
  to localStorage
- payouts.js + admin Export CSV (tab #6) — mp_payout_batches batching,
  ledger flip to paid + payout_batch_id
- challenges.js + leaderboard.html + challenges.html + challenge.html
  (tab #7) — top-N designers by points/sales/saves/collections +
  mp_challenges scheduling + auto-award CHALLENGE_WIN
- seo.js + sitemap (tab #8) — /sitemap-marketplace.xml, JSON-LD Person
  on designers, JSON-LD Product on patterns
- scripts/marketplace-watcher.js — pm2 service polling status every 60s,
  emits data/marketplace/status-watch.json + events.jsonl + deltas at
  /api/marketplace/watcher and /api/marketplace/events
- status.html enhanced w/ Deltas grid (5m/30m/1h/24h) + event feed
- nav extended w/ Leaderboard + Challenges
- search.test.js + marketplace-seo.test.js + payouts.test.js
- sql/200_dig_auto_assign.sql

Tests: 32/32 still green. pm2: wallco-ai (145) + wallco-mp-watcher (149).
Status board live every 4s at /marketplace/status.

Files touched

Diff

commit faa24dd1247e81bad0e2454af7c7e90f8239b91d
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 13 14:52:13 2026 -0700

    marketplace YOLO loop · overnight milestone 2 — parallel-tab landings
    
    Seven parallel iTerm Claude tabs grinding overnight. Net 2,556 LOC of
    additive marketplace progress (Gemini-generated PNGs gitignored):
    
    - ai.js Gemini wire (tab #1) — gemini-2.5-flash-image recolor with 6
      colorway directions (Warm Clay/Moss & Linen/Ink Blue/Rose Plaster/
      Gold Smoke/Pearl Frost), PIL palette extract, cost-tracker hook,
      persists into mp_pattern_colorways
    - orders.js (tab #3) — sample/wallpaper Shopify draft-order pipeline +
      fulfilled-webhook → mp_order_items + mp_commission_ledger
    - patterns.html + /api/marketplace/search (tab #4) — FTS query +
      style/room/color/commercial filters, debounced live search persisted
      to localStorage
    - payouts.js + admin Export CSV (tab #6) — mp_payout_batches batching,
      ledger flip to paid + payout_batch_id
    - challenges.js + leaderboard.html + challenges.html + challenge.html
      (tab #7) — top-N designers by points/sales/saves/collections +
      mp_challenges scheduling + auto-award CHALLENGE_WIN
    - seo.js + sitemap (tab #8) — /sitemap-marketplace.xml, JSON-LD Person
      on designers, JSON-LD Product on patterns
    - scripts/marketplace-watcher.js — pm2 service polling status every 60s,
      emits data/marketplace/status-watch.json + events.jsonl + deltas at
      /api/marketplace/watcher and /api/marketplace/events
    - status.html enhanced w/ Deltas grid (5m/30m/1h/24h) + event feed
    - nav extended w/ Leaderboard + Challenges
    - search.test.js + marketplace-seo.test.js + payouts.test.js
    - sql/200_dig_auto_assign.sql
    
    Tests: 32/32 still green. pm2: wallco-ai (145) + wallco-mp-watcher (149).
    Status board live every 4s at /marketplace/status.
---
 .gitignore                          |   4 +
 data/marketplace/events.jsonl       |   3 +
 data/marketplace/status-watch.json  |  82 ++++++
 data/marketplace/status-watch.jsonl |   4 +
 public/marketplace/_partials.js     |   2 +
 public/marketplace/challenge.html   | 115 ++++++++
 public/marketplace/challenges.html  | 114 ++++++++
 public/marketplace/leaderboard.html | 172 ++++++++++++
 public/marketplace/patterns.html    |  94 ++++++-
 public/marketplace/status.html      |  72 +++++
 sql/200_dig_auto_assign.sql         |  26 ++
 src/marketplace/ai.js               | 544 +++++++++++++++++++++++++++++++++---
 src/marketplace/challenges.js       | 279 ++++++++++++++++++
 src/marketplace/orders.js           | 370 ++++++++++++++++++++++++
 src/marketplace/seo.js              | 269 ++++++++++++++++++
 src/marketplace/shopify.js          |  56 ++++
 tests/marketplace/search.test.js    | 134 +++++++++
 tests/unit/marketplace-seo.test.js  | 262 +++++++++++++++++
 18 files changed, 2556 insertions(+), 46 deletions(-)

diff --git a/.gitignore b/.gitignore
index d8a671a..ae98bd5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,7 @@ data/moodboards.json
 data/reviews.json
 data/sample-requests.jsonl
 data/furnish-cache/
+public/marketplace/uploads/colorway-*.png
+public/marketplace/uploads/colorway-*.jpg
+public/marketplace/uploads/colorway-*.webp
+data/marketplace/payouts/
diff --git a/data/marketplace/events.jsonl b/data/marketplace/events.jsonl
new file mode 100644
index 0000000..96771ce
--- /dev/null
+++ b/data/marketplace/events.jsonl
@@ -0,0 +1,3 @@
+{"type":"startup","host":"127.0.0.1","port":9792,"ts":"2026-05-13T21:48:44.080Z"}
+{"type":"delta","key":"open_inquiries","label":"new licensing inquiry","change":3,"total":5,"ts":"2026-05-13T21:50:44.135Z"}
+{"type":"delta","key":"open_inquiries","label":"new licensing inquiry","change":3,"total":5,"ts":"2026-05-13T21:51:44.106Z"}
diff --git a/data/marketplace/status-watch.json b/data/marketplace/status-watch.json
new file mode 100644
index 0000000..7bc37e4
--- /dev/null
+++ b/data/marketplace/status-watch.json
@@ -0,0 +1,82 @@
+{
+  "ts": "2026-05-13T21:51:44.105Z",
+  "db": true,
+  "counts": {
+    "designers": "6",
+    "designers_approved": "5",
+    "founding": "3",
+    "unclaimed": "4",
+    "patterns": "24",
+    "patterns_approved": "24",
+    "patterns_pending": "0",
+    "collections": "8",
+    "commission_entries": "6",
+    "pending_commission": "545.60",
+    "paid_commission": "0",
+    "open_inquiries": "5",
+    "open_takedowns": "0"
+  },
+  "deltas": {
+    "5m": {
+      "designers": 0,
+      "designers_approved": 0,
+      "founding": 0,
+      "unclaimed": 0,
+      "patterns": 0,
+      "patterns_approved": 0,
+      "patterns_pending": 0,
+      "collections": 0,
+      "commission_entries": 0,
+      "pending_commission": 0,
+      "paid_commission": 0,
+      "open_inquiries": 3,
+      "open_takedowns": 0
+    },
+    "30m": {
+      "designers": 0,
+      "designers_approved": 0,
+      "founding": 0,
+      "unclaimed": 0,
+      "patterns": 0,
+      "patterns_approved": 0,
+      "patterns_pending": 0,
+      "collections": 0,
+      "commission_entries": 0,
+      "pending_commission": 0,
+      "paid_commission": 0,
+      "open_inquiries": 3,
+      "open_takedowns": 0
+    },
+    "60m": {
+      "designers": 0,
+      "designers_approved": 0,
+      "founding": 0,
+      "unclaimed": 0,
+      "patterns": 0,
+      "patterns_approved": 0,
+      "patterns_pending": 0,
+      "collections": 0,
+      "commission_entries": 0,
+      "pending_commission": 0,
+      "paid_commission": 0,
+      "open_inquiries": 3,
+      "open_takedowns": 0
+    },
+    "24h": {
+      "designers": 0,
+      "designers_approved": 0,
+      "founding": 0,
+      "unclaimed": 0,
+      "patterns": 0,
+      "patterns_approved": 0,
+      "patterns_pending": 0,
+      "collections": 0,
+      "commission_entries": 0,
+      "pending_commission": 0,
+      "paid_commission": 0,
+      "open_inquiries": 3,
+      "open_takedowns": 0
+    }
+  },
+  "history_size": 4
+}
\ No newline at end of file
diff --git a/data/marketplace/status-watch.jsonl b/data/marketplace/status-watch.jsonl
new file mode 100644
index 0000000..f0b3f5f
--- /dev/null
+++ b/data/marketplace/status-watch.jsonl
@@ -0,0 +1,4 @@
+{"ts":1778708924108,"counts":{"designers":"6","designers_approved":"5","founding":"3","unclaimed":"4","patterns":"24","patterns_approved":"24","patterns_pending":"0","collections":"8","commission_entries":"6","pending_commission":"545.60","paid_commission":"0","open_inquiries":"2","open_takedowns":"0"},"db":true}
+{"ts":1778708984094,"counts":{"designers":"6","designers_approved":"5","founding":"3","unclaimed":"4","patterns":"24","patterns_approved":"24","patterns_pending":"0","collections":"8","commission_entries":"6","pending_commission":"545.60","paid_commission":"0","open_inquiries":"2","open_takedowns":"0"},"db":true}
+{"ts":1778709044134,"counts":{"designers":"6","designers_approved":"5","founding":"3","unclaimed":"4","patterns":"24","patterns_approved":"24","patterns_pending":"0","collections":"8","commission_entries":"6","pending_commission":"545.60","paid_commission":"0","open_inquiries":"5","open_takedowns":"0"},"db":true}
+{"ts":1778709104105,"counts":{"designers":"6","designers_approved":"5","founding":"3","unclaimed":"4","patterns":"24","patterns_approved":"24","patterns_pending":"0","collections":"8","commission_entries":"6","pending_commission":"545.60","paid_commission":"0","open_inquiries":"5","open_takedowns":"0"},"db":true}
diff --git a/public/marketplace/_partials.js b/public/marketplace/_partials.js
index 18d0d31..3f82d44 100644
--- a/public/marketplace/_partials.js
+++ b/public/marketplace/_partials.js
@@ -7,6 +7,8 @@
       ['/marketplace',           'Marketplace'],
       ['/designers',             'Designers'],
       ['/patterns',              'Patterns'],
+      ['/marketplace/leaderboard', 'Leaderboard'],
+      ['/marketplace/challenges',  'Challenges'],
       ['/marketplace/apply',     'Become a Designer'],
       ['/marketplace/dashboard', 'Dashboard'],
       ['/marketplace/status',    'Status'],
diff --git a/public/marketplace/challenge.html b/public/marketplace/challenge.html
new file mode 100644
index 0000000..dfef8a5
--- /dev/null
+++ b/public/marketplace/challenge.html
@@ -0,0 +1,115 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<title>Wallco · Challenge</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>
+  .cd-wrap { max-width: 1000px; margin: 0 auto; padding: var(--mp-pad); }
+  .cd-hero { padding: clamp(36px,5vw,80px) var(--mp-pad) 12px; }
+  .cd-hero h1 { font-size: clamp(30px,3.6vw,48px); margin: 0 0 8px; letter-spacing: -.02em; }
+  .cd-hero .meta { color: var(--mp-muted); font-size: 14px; line-height: 1.55; }
+  .cd-hero .pills { display: flex; flex-wrap: wrap; gap: 8px; margin: 14px 0; }
+  .cd-hero .pills .metric { background: #111; color: #fff; padding: 4px 12px; border-radius: 999px; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; }
+  .cd-hero .pills .status { padding: 4px 12px; border-radius: 999px; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; }
+  .cd-hero .pills .status.active { background: #0a7; color: #fff; }
+  .cd-hero .pills .status.scheduled { background: rgba(20,20,20,.08); color: #444; }
+  .cd-hero .pills .status.closed { background: #cda86e; color: #2c2110; }
+  .cd-table { background: var(--mp-card); border: 1px solid var(--mp-border); border-radius: 22px; overflow: hidden; box-shadow: var(--mp-glass-shadow); margin-top: 24px; }
+  .cd-row { display: grid; grid-template-columns: 56px 1fr auto; gap: 12px; align-items: center; padding: 14px 18px; border-bottom: 1px solid var(--mp-border); }
+  .cd-row:last-child { border-bottom: 0; }
+  .cd-row .rank { font-weight: 700; font-size: 18px; color: #555; text-align: center; }
+  .cd-row .rank.top1 { color: #b78a2a; }
+  .cd-row .rank.top2 { color: #888; }
+  .cd-row .rank.top3 { color: #a66; }
+  .cd-row .designer { display: flex; align-items: center; gap: 12px; min-width: 0; }
+  .cd-row .designer .avatar { width: 38px; height: 38px; border-radius: 50%; background: #ddd; background-size: cover; background-position: center; flex: 0 0 38px; }
+  .cd-row .designer .name { font-weight: 600; font-size: 15px; }
+  .cd-row .designer .level { font-size: 12px; color: var(--mp-muted); }
+  .cd-row .score { font-weight: 700; font-variant-numeric: tabular-nums; }
+  .cd-winner { padding: 22px 24px; background: linear-gradient(135deg,#fdf6e1,#fcedc6); border: 1px solid #e8d6a0; border-radius: 22px; margin-top: 16px; display: flex; align-items: center; gap: 18px; }
+  .cd-winner .avatar { width: 64px; height: 64px; border-radius: 50%; background: #ddd; background-size: cover; background-position: center; flex: 0 0 64px; }
+  .cd-winner .label { text-transform: uppercase; letter-spacing: .08em; font-size: 11px; color: #715a1f; }
+  .cd-winner .name { font-weight: 700; font-size: 20px; color: #2c2110; }
+  .cd-winner .score { color: #715a1f; font-size: 14px; }
+</style>
+</head>
+<body>
+  <main class="cd-wrap">
+    <header class="cd-hero">
+      <div style="font-size:13px;margin-bottom:10px"><a class="under" href="/marketplace/challenges">← All challenges</a> · <a class="under" href="/marketplace/leaderboard">Leaderboard</a></div>
+      <h1 id="title">Loading…</h1>
+      <div class="pills" id="pills"></div>
+      <div class="meta" id="desc"></div>
+      <div class="meta" id="window"></div>
+    </header>
+
+    <div id="winner"></div>
+
+    <section style="margin-top:24px">
+      <h2 style="font-size:20px;letter-spacing:-.01em;margin:0 0 12px">Top 50</h2>
+      <div id="board" class="cd-table"></div>
+    </section>
+  </main>
+
+<script>
+function rankClass(rank) { if (rank===1) return 'top1'; if (rank===2) return 'top2'; if (rank===3) return 'top3'; return ''; }
+function fmtDate(iso) { return iso ? new Date(iso).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}) : ''; }
+
+async function load() {
+  const id = location.pathname.split('/').pop();
+  let json;
+  try { json = await MP.api('/api/marketplace/challenges/' + encodeURIComponent(id)); }
+  catch (e) { document.getElementById('title').textContent = 'Not found'; return; }
+  const c = json.challenge;
+  document.title = 'Wallco · ' + c.title;
+  document.getElementById('title').textContent = c.title;
+  document.getElementById('desc').textContent = c.description || '';
+  document.getElementById('window').textContent = fmtDate(c.starts_at) + ' → ' + fmtDate(c.ends_at) + ' · metric: ' + c.metric;
+  document.getElementById('pills').innerHTML = `<span class="metric">${c.metric}</span> <span class="status ${c.status}">${c.status}</span>`;
+
+  if (c.status === 'closed' && c.winner_designer_id) {
+    const w = document.getElementById('winner');
+    const avatarStyle = c.winner_avatar ? `background-image:url(${c.winner_avatar.replace(/"/g,'%22')})` : 'background:linear-gradient(135deg,#e9e3d8,#cdbfa6)';
+    w.className = 'cd-winner';
+    w.innerHTML = `
+      <div class="avatar" style="${avatarStyle}"></div>
+      <div style="flex:1;min-width:0">
+        <div class="label">Winner · +1000 pts · Top Seller badge</div>
+        <div class="name"><a href="/designers/${c.winner_slug}">${c.winner_name}</a></div>
+        <div class="score">Score: ${Number(c.winner_score||0).toLocaleString()} ${c.metric === 'points' ? 'points' : c.metric}</div>
+      </div>`;
+  }
+
+  const board = document.getElementById('board');
+  const rows = json.leaderboard || [];
+  if (!rows.length) {
+    board.innerHTML = '<div style="padding:24px;text-align:center;color:var(--mp-muted)">No entries yet.</div>';
+    return;
+  }
+  rows.forEach((r, i) => {
+    const rank = r.rank || (i + 1);
+    const avatarStyle = r.avatar_url ? `background-image:url(${r.avatar_url.replace(/"/g,'%22')})` : 'background:linear-gradient(135deg,#e9e3d8,#cdbfa6)';
+    const row = document.createElement('div');
+    row.className = 'cd-row';
+    row.innerHTML = `
+      <div class="rank ${rankClass(rank)}">#${rank}</div>
+      <div class="designer">
+        <div class="avatar" style="${avatarStyle}"></div>
+        <div>
+          <div class="name"><a href="/designers/${r.slug}">${r.display_name}</a></div>
+          <div class="level">${r.level || ''}</div>
+        </div>
+      </div>
+      <div class="score">${Number(r.score || 0).toLocaleString()}</div>
+    `;
+    board.appendChild(row);
+  });
+}
+
+document.addEventListener('DOMContentLoaded', load);
+</script>
+</body>
+</html>
diff --git a/public/marketplace/challenges.html b/public/marketplace/challenges.html
new file mode 100644
index 0000000..a112370
--- /dev/null
+++ b/public/marketplace/challenges.html
@@ -0,0 +1,114 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<title>Wallco · Monthly Challenges</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>
+  .ch-wrap { max-width: 1100px; margin: 0 auto; padding: var(--mp-pad); }
+  .ch-hero { padding: clamp(36px,5vw,80px) var(--mp-pad) 12px; }
+  .ch-hero h1 { font-size: clamp(34px,4vw,56px); margin: 0 0 10px; letter-spacing: -.02em; }
+  .ch-hero p  { color: var(--mp-muted); max-width: 720px; margin: 0; line-height: 1.55; }
+  .ch-section h2 { font-size: 22px; letter-spacing: -.01em; margin: 28px 0 14px; }
+  .ch-grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(280px,1fr)); }
+  .ch-card { padding: 18px 20px; background: var(--mp-card); border: 1px solid var(--mp-border); border-radius: 20px; box-shadow: var(--mp-glass-shadow); display: grid; gap: 10px; transition: transform .15s ease, box-shadow .15s ease; }
+  .ch-card:hover { transform: translateY(-2px); box-shadow: 0 12px 36px rgba(20,20,20,.08); }
+  .ch-card .ttl { font-weight: 700; font-size: 17px; letter-spacing: -.01em; }
+  .ch-card .sub { font-size: 13px; color: var(--mp-muted); line-height: 1.5; }
+  .ch-card .metric { display: inline-block; padding: 3px 9px; border-radius: 999px; background: #111; color: #fff; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; width: max-content; }
+  .ch-card .status { display:inline-block; padding: 3px 9px; border-radius: 999px; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; width: max-content; }
+  .ch-card .status.active { background: #0a7; color: #fff; }
+  .ch-card .status.scheduled { background: rgba(20,20,20,.08); color: #444; }
+  .ch-card .status.closed { background: #cda86e; color: #2c2110; }
+  .ch-card .winner { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: rgba(20,20,20,.04); border-radius: 14px; }
+  .ch-card .winner .avatar { width: 32px; height: 32px; border-radius: 50%; background: #ddd; background-size: cover; background-position: center; flex: 0 0 32px; }
+  .ch-card .winner .name { font-weight: 600; font-size: 14px; }
+  .ch-card .winner .label { font-size: 11px; color: var(--mp-muted); text-transform: uppercase; letter-spacing: .04em; }
+  .ch-empty { padding: 28px; text-align: center; color: var(--mp-muted); }
+</style>
+</head>
+<body>
+  <header class="ch-hero ch-wrap">
+    <h1>Monthly Challenges</h1>
+    <p>Compete with the Wallco designer community. Each challenge is a time-boxed window — top designers by the challenge metric earn 1,000 bonus points and the Top Seller badge.</p>
+  </header>
+
+  <main class="ch-wrap">
+    <section class="ch-section">
+      <h2>Active</h2>
+      <div id="active" class="ch-grid"></div>
+    </section>
+
+    <section class="ch-section">
+      <h2>Past winners</h2>
+      <div id="closed" class="ch-grid"></div>
+    </section>
+
+    <section class="ch-section">
+      <h2>Coming soon</h2>
+      <div id="scheduled" class="ch-grid"></div>
+    </section>
+  </main>
+
+<script>
+function fmtDate(iso) { if (!iso) return ''; return new Date(iso).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}); }
+function dayRange(c) {
+  return fmtDate(c.starts_at) + ' → ' + fmtDate(c.ends_at);
+}
+function challengeCard(c) {
+  const card = document.createElement('a');
+  card.className = 'ch-card';
+  card.href = '/marketplace/challenges/' + c.id;
+  let winnerHtml = '';
+  if (c.status === 'closed' && c.winner_designer_id) {
+    const avatarStyle = c.winner_avatar ? `background-image:url(${c.winner_avatar.replace(/"/g,'%22')})` : 'background:linear-gradient(135deg,#e9e3d8,#cdbfa6)';
+    winnerHtml = `
+      <div class="winner">
+        <div class="avatar" style="${avatarStyle}"></div>
+        <div>
+          <div class="label">Winner</div>
+          <div class="name">${c.winner_name || ''}</div>
+        </div>
+      </div>`;
+  }
+  card.innerHTML = `
+    <div style="display:flex;gap:8px;flex-wrap:wrap">
+      <span class="metric">${c.metric}</span>
+      <span class="status ${c.status}">${c.status}</span>
+    </div>
+    <div class="ttl">${c.title}</div>
+    <div class="sub">${c.description || ''}</div>
+    <div class="sub">${dayRange(c)}</div>
+    ${winnerHtml}
+  `;
+  return card;
+}
+
+async function load() {
+  let json;
+  try { json = await MP.api('/api/marketplace/challenges'); } catch (e) { return; }
+  const all = json.challenges || [];
+  const buckets = { active: [], scheduled: [], closed: [], cancelled: [] };
+  all.forEach(c => { (buckets[c.status] || (buckets[c.status]=[])).push(c); });
+  const sections = [
+    ['active', 'No active challenges right now. Check back soon.'],
+    ['closed', 'No closed challenges yet.'],
+    ['scheduled', 'Nothing scheduled. Admins can create one from the admin console.'],
+  ];
+  sections.forEach(([k, emptyMsg]) => {
+    const wrap = document.getElementById(k);
+    const rows = buckets[k] || [];
+    if (!rows.length) {
+      wrap.innerHTML = `<div class="ch-empty">${emptyMsg}</div>`;
+      return;
+    }
+    rows.forEach(c => wrap.appendChild(challengeCard(c)));
+  });
+}
+
+document.addEventListener('DOMContentLoaded', load);
+</script>
+</body>
+</html>
diff --git a/public/marketplace/leaderboard.html b/public/marketplace/leaderboard.html
new file mode 100644
index 0000000..c2b6f1b
--- /dev/null
+++ b/public/marketplace/leaderboard.html
@@ -0,0 +1,172 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<title>Wallco · Designer Leaderboard</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>
+  .lb-wrap { max-width: 1100px; margin: 0 auto; padding: var(--mp-pad); }
+  .lb-hero { padding: clamp(36px,5vw,80px) var(--mp-pad) 12px; }
+  .lb-hero h1 { font-size: clamp(34px,4vw,56px); margin: 0 0 10px; letter-spacing: -.02em; }
+  .lb-hero p  { color: var(--mp-muted); max-width: 720px; margin: 0; line-height: 1.55; }
+
+  .lb-tabs { display: flex; flex-wrap: wrap; gap: 8px; padding: 8px; background: var(--mp-card); border: 1px solid var(--mp-border); border-radius: 999px; box-shadow: var(--mp-glass-shadow); width: max-content; max-width: 100%; margin-bottom: 22px; }
+  .lb-tab { background: transparent; border: 0; padding: 9px 18px; border-radius: 999px; font-size: 13px; font-weight: 600; color: #444; cursor: pointer; transition: background .15s ease, color .15s ease; }
+  .lb-tab:hover { background: rgba(20,20,20,.04); }
+  .lb-tab.active { background: #111; color: #fff; }
+
+  .lb-table { width: 100%; background: var(--mp-card); border: 1px solid var(--mp-border); border-radius: 22px; overflow: hidden; box-shadow: var(--mp-glass-shadow); }
+  .lb-row { display: grid; grid-template-columns: 56px 1fr auto; gap: 12px; align-items: center; padding: 14px 18px; border-bottom: 1px solid var(--mp-border); transition: background .15s ease; }
+  .lb-row:last-child { border-bottom: 0; }
+  .lb-row:hover { background: rgba(20,20,20,.02); }
+  .lb-row .rank { font-size: 18px; font-weight: 700; color: #444; min-width: 34px; text-align: center; }
+  .lb-row .rank.top1 { color: #b78a2a; }
+  .lb-row .rank.top2 { color: #888; }
+  .lb-row .rank.top3 { color: #a66; }
+  .lb-row .designer { display: flex; align-items: center; gap: 14px; min-width: 0; }
+  .lb-row .designer .avatar { width: 40px; height: 40px; border-radius: 50%; background: #ddd; background-size: cover; background-position: center; flex: 0 0 40px; }
+  .lb-row .designer .meta { display: flex; flex-direction: column; min-width: 0; }
+  .lb-row .designer .name { font-weight: 600; font-size: 15px; }
+  .lb-row .designer .name a { color: inherit; }
+  .lb-row .designer .name a:hover { text-decoration: underline; text-underline-offset: 3px; }
+  .lb-row .designer .level { font-size: 12px; color: var(--mp-muted); }
+  .lb-row .score { font-variant-numeric: tabular-nums; font-weight: 700; font-size: 16px; }
+  .lb-row .pills { display: flex; gap: 6px; margin-left: 8px; }
+  .lb-empty { padding: 28px; text-align: center; color: var(--mp-muted); }
+
+  .lb-challenges { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(260px,1fr)); margin: 24px 0 36px; }
+  .lb-challenge { padding: 16px 18px; background: var(--mp-card); border: 1px solid var(--mp-border); border-radius: 18px; box-shadow: var(--mp-glass-shadow); display: grid; gap: 6px; }
+  .lb-challenge .ttl { font-weight: 700; font-size: 15px; }
+  .lb-challenge .sub { font-size: 12px; color: var(--mp-muted); }
+  .lb-challenge .metric { display: inline-block; padding: 3px 9px; border-radius: 999px; background: #111; color: #fff; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; width: max-content; }
+  .lb-meta { color: var(--mp-muted); font-size: 13px; margin-bottom: 12px; }
+
+  @media (max-width: 560px) {
+    .lb-row { grid-template-columns: 40px 1fr auto; padding: 12px; }
+    .lb-row .designer .avatar { width: 32px; height: 32px; flex-basis: 32px; }
+    .lb-tab { padding: 8px 14px; font-size: 12px; }
+  }
+</style>
+</head>
+<body>
+  <header class="lb-hero lb-wrap">
+    <h1>Designer Leaderboard</h1>
+    <p>The top 50 Wallco designers — by points earned, wallpaper sales, trade saves, and published collections. Updated continuously from the marketplace ledger.</p>
+  </header>
+
+  <main class="lb-wrap">
+
+    <section id="active-section" style="display:none">
+      <h2 style="font-size:20px;letter-spacing:-.01em;margin:0 0 12px">Active challenges</h2>
+      <div id="active-challenges" class="lb-challenges"></div>
+    </section>
+
+    <div class="lb-tabs" role="tablist" aria-label="Leaderboard metric">
+      <button class="lb-tab active" data-metric="points"      role="tab">Points</button>
+      <button class="lb-tab"        data-metric="sales"       role="tab">Sales</button>
+      <button class="lb-tab"        data-metric="saves"       role="tab">Trade saves</button>
+      <button class="lb-tab"        data-metric="collections" role="tab">Collections</button>
+    </div>
+
+    <div id="meta" class="lb-meta">Loading…</div>
+    <div id="board" class="lb-table" aria-live="polite"></div>
+
+  </main>
+
+<script>
+const METRIC_LABEL = { points: 'lifetime points', sales: 'paid + fulfilled wallpaper orders', saves: 'trade project saves', collections: 'published collections' };
+const METRIC_UNIT  = { points: 'pts',             sales: 'orders',                            saves: 'saves',              collections: 'collections' };
+
+function rankClass(rank) {
+  if (rank === 1) return 'top1';
+  if (rank === 2) return 'top2';
+  if (rank === 3) return 'top3';
+  return '';
+}
+
+function fmtScore(metric, score) {
+  const n = Number(score || 0);
+  return n.toLocaleString(undefined, { maximumFractionDigits: metric === 'points' ? 0 : 0 });
+}
+
+async function loadBoard(metric) {
+  const meta = document.getElementById('meta');
+  const board = document.getElementById('board');
+  meta.textContent = 'Loading…';
+  board.innerHTML = '';
+  let json;
+  try { json = await MP.api('/api/marketplace/leaderboard?metric=' + encodeURIComponent(metric) + '&limit=50'); }
+  catch (e) { meta.textContent = 'Could not load leaderboard: ' + e.message; return; }
+  const rows = json.leaderboard || [];
+  meta.textContent = 'Top ' + rows.length + ' designers by ' + (METRIC_LABEL[metric] || metric) + '.';
+  if (!rows.length) {
+    board.innerHTML = '<div class="lb-empty">No designers yet. Be the first — <a class="under" href="/marketplace/apply">apply to become a Wallco designer</a>.</div>';
+    return;
+  }
+  rows.forEach(r => {
+    const row = document.createElement('div');
+    row.className = 'lb-row';
+    const avatarStyle = r.avatar_url ? `background-image:url(${r.avatar_url.replace(/"/g,'%22')})` : 'background:linear-gradient(135deg,#e9e3d8,#cdbfa6)';
+    const pills = [];
+    if (r.is_founding) pills.push('<span class="mp-pill gold">Founding</span>');
+    if (r.is_verified) pills.push('<span class="mp-pill verified">✓</span>');
+    row.innerHTML = `
+      <div class="rank ${rankClass(r.rank)}">#${r.rank}</div>
+      <div class="designer">
+        <div class="avatar" style="${avatarStyle}"></div>
+        <div class="meta">
+          <div class="name"><a href="/designers/${r.slug}">${r.display_name || r.slug}</a></div>
+          <div class="level">${r.level || ''}</div>
+        </div>
+        <div class="pills">${pills.join('')}</div>
+      </div>
+      <div class="score">${fmtScore(metric, r.score)} <span style="font-size:12px;color:var(--mp-muted);font-weight:500">${METRIC_UNIT[metric]||''}</span></div>
+    `;
+    board.appendChild(row);
+  });
+
+  const active = (json.active_challenges || []);
+  const activeWrap = document.getElementById('active-challenges');
+  const activeSection = document.getElementById('active-section');
+  activeWrap.innerHTML = '';
+  if (active.length) {
+    activeSection.style.display = '';
+    active.forEach(c => {
+      const card = document.createElement('a');
+      card.className = 'lb-challenge';
+      card.href = '/marketplace/challenges/' + c.id;
+      const ends = c.ends_at ? new Date(c.ends_at).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}) : '';
+      card.innerHTML = `
+        <span class="metric">${c.metric}</span>
+        <div class="ttl">${c.title}</div>
+        <div class="sub">${c.description ? c.description : ''}</div>
+        <div class="sub">Ends ${ends}</div>
+      `;
+      activeWrap.appendChild(card);
+    });
+  } else {
+    activeSection.style.display = 'none';
+  }
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+  const tabs = document.querySelectorAll('.lb-tab');
+  tabs.forEach(t => t.addEventListener('click', () => {
+    tabs.forEach(x => x.classList.toggle('active', x === t));
+    const metric = t.dataset.metric;
+    history.replaceState(null,'','?metric=' + metric);
+    loadBoard(metric);
+  }));
+  const initial = new URL(location.href).searchParams.get('metric');
+  if (initial && ['points','sales','saves','collections'].includes(initial)) {
+    tabs.forEach(x => x.classList.toggle('active', x.dataset.metric === initial));
+    loadBoard(initial);
+  } else {
+    loadBoard('points');
+  }
+});
+</script>
+</body>
+</html>
diff --git a/public/marketplace/patterns.html b/public/marketplace/patterns.html
index e3aa2ef..6a800a8 100644
--- a/public/marketplace/patterns.html
+++ b/public/marketplace/patterns.html
@@ -8,59 +8,119 @@
 <script src="/marketplace/_partials.js" defer></script>
 <style>
   .controls { display:flex; gap: 10px; flex-wrap:wrap; margin: 14px 0 22px; align-items:center; }
-  .controls select { padding: 8px 12px; border-radius: 999px; border: 1px solid var(--mp-border); background: rgba(255,255,255,.8); font-size: 14px; }
+  .controls select, .controls input[type="search"], .controls input[type="text"] {
+    padding: 8px 12px; border-radius: 999px; border: 1px solid var(--mp-border);
+    background: rgba(255,255,255,.8); font-size: 14px;
+  }
+  .controls input[type="search"] { min-width: 240px; }
+  .controls label.chk { display:inline-flex; gap:6px; align-items:center; font-size:14px; color:var(--mp-ink-2); }
+  .meta { color: var(--mp-ink-2); font-size: 13px; margin: 0 0 12px; }
 </style>
 </head>
 <body>
   <header class="mp-hero">
     <h1>Patterns</h1>
-    <p class="lede">Every pattern is uploaded by an independent designer, rights-confirmed, and produced through Designer Wallcoverings. Sort, filter, sample.</p>
+    <p class="lede">Every pattern is uploaded by an independent designer, rights-confirmed, and produced through Designer Wallcoverings. Search, sort, sample.</p>
   </header>
   <main class="mp-wrap">
     <div class="controls">
+      <input type="search" id="q" placeholder="Search title, description, tags…" autocomplete="off" />
+      <span class="sub">Style</span>
+      <input type="text" id="style" placeholder="e.g. floral, modern" size="14" />
+      <span class="sub">Room</span>
+      <input type="text" id="room" placeholder="e.g. bedroom" size="12" />
+      <span class="sub">Color</span>
+      <input type="text" id="color" placeholder="e.g. blue, gold" size="14" />
+      <label class="chk"><input type="checkbox" id="commercial"/> Commercial only</label>
       <span class="sub">Sort</span>
-      <select id="sort"><option value="featured">Featured first</option><option value="newest">Newest</option></select>
+      <select id="sort">
+        <option value="featured">Featured first</option>
+        <option value="newest">Newest</option>
+      </select>
       <span class="sub">Designer</span>
       <select id="designer"><option value="">All</option></select>
       <span class="sub" style="margin-left:14px">Density</span>
       <input type="range" id="density" min="200" max="380" value="280" step="10"/>
     </div>
+    <p class="meta" id="meta"></p>
     <div class="mp-grid lg" id="grid"></div>
   </main>
 
 <script>
 const grid = document.getElementById('grid');
+const qInput = document.getElementById('q');
+const styleInput = document.getElementById('style');
+const roomInput = document.getElementById('room');
+const colorInput = document.getElementById('color');
+const commercialInput = document.getElementById('commercial');
 const sortSel = document.getElementById('sort');
 const dSel = document.getElementById('designer');
 const density = document.getElementById('density');
+const meta = document.getElementById('meta');
 
 const KEY = 'wallco.marketplace.patterns';
 const saved = JSON.parse(localStorage.getItem(KEY) || '{}');
 if (saved.density) density.value = saved.density;
+if (saved.q) qInput.value = saved.q;
+if (saved.style) styleInput.value = saved.style;
+if (saved.room) roomInput.value = saved.room;
+if (saved.color) colorInput.value = saved.color;
+if (saved.commercial) commercialInput.checked = !!saved.commercial;
+if (saved.sort) sortSel.value = saved.sort;
 applyDensity();
 
 function applyDensity() { grid.style.gridTemplateColumns = `repeat(auto-fill, minmax(${density.value}px, 1fr))`; }
 
+function saveState() {
+  localStorage.setItem(KEY, JSON.stringify({
+    density: density.value,
+    q: qInput.value,
+    style: styleInput.value,
+    room: roomInput.value,
+    color: colorInput.value,
+    commercial: commercialInput.checked,
+    sort: sortSel.value,
+  }));
+}
+
 async function loadDesigners() {
   const j = await fetch('/api/marketplace/designers?limit=120').then(r => r.json());
   (j.designers || []).forEach(d => {
     const o = document.createElement('option'); o.value=d.slug; o.textContent=d.display_name; dSel.appendChild(o);
   });
 }
+
+function hasFilters() {
+  return !!(qInput.value.trim() || styleInput.value.trim() || roomInput.value.trim() || colorInput.value.trim() || commercialInput.checked);
+}
+
 async function load() {
-  localStorage.setItem(KEY, JSON.stringify({ density: density.value }));
-  const params = new URLSearchParams({ limit: '120' });
-  if (dSel.value) params.set('designer', dSel.value);
-  const j = await fetch('/api/marketplace/patterns?' + params).then(r => r.json());
-  grid.innerHTML = '';
+  saveState();
+  let url, j;
+  if (hasFilters()) {
+    const params = new URLSearchParams({ limit: '120' });
+    if (qInput.value.trim()) params.set('q', qInput.value.trim());
+    if (styleInput.value.trim()) params.set('style', styleInput.value.trim());
+    if (roomInput.value.trim()) params.set('room', roomInput.value.trim());
+    if (colorInput.value.trim()) params.set('color', colorInput.value.trim());
+    if (commercialInput.checked) params.set('commercial', 'true');
+    url = '/api/marketplace/search?' + params;
+  } else {
+    const params = new URLSearchParams({ limit: '120' });
+    if (dSel.value) params.set('designer', dSel.value);
+    url = '/api/marketplace/patterns?' + params;
+  }
+  j = await fetch(url).then(r => r.json());
   let patterns = j.patterns || [];
-  if (sortSel.value === 'newest') {
-    // server already returns featured first; for newest just keep order
+  if (sortSel.value === 'newest' && hasFilters()) {
+    // ranked results — re-sort by id desc as a proxy for newest
+    patterns = patterns.slice().sort((a,b) => (b.id||0) - (a.id||0));
   }
+  grid.innerHTML = '';
   patterns.forEach(p => {
     const a = document.createElement('a'); a.className='mp-card'; a.href=`/patterns/${p.slug}`;
     a.innerHTML = `
-      <div class="img" style="background-image:url('${p.thumbnail_url}')"></div>
+      <div class="img" style="background-image:url('${p.thumbnail_url || p.original_image_url || ''}')"></div>
       <div class="body">
         <div class="title">${p.title}</div>
         <div class="sub">by ${p.designer_name}</div>
@@ -71,10 +131,20 @@ async function load() {
       </div>`;
     grid.appendChild(a);
   });
+  meta.textContent = patterns.length
+    ? `${patterns.length} pattern${patterns.length===1?'':'s'}${hasFilters() ? ' · filtered' : ''}`
+    : (hasFilters() ? 'No patterns match those filters.' : 'No patterns yet.');
 }
+
+let qTimer = null;
+function debounce(fn, ms){ return () => { clearTimeout(qTimer); qTimer = setTimeout(fn, ms); }; }
+const debouncedLoad = debounce(load, 250);
+
+[qInput, styleInput, roomInput, colorInput].forEach(el => el.addEventListener('input', debouncedLoad));
+commercialInput.addEventListener('change', load);
 sortSel.addEventListener('change', load);
 dSel.addEventListener('change', load);
-density.addEventListener('input', () => { applyDensity(); localStorage.setItem(KEY, JSON.stringify({ density: density.value })); });
+density.addEventListener('input', () => { applyDensity(); saveState(); });
 loadDesigners().then(load);
 </script>
 </body>
diff --git a/public/marketplace/status.html b/public/marketplace/status.html
index 55488e8..83bf1e0 100644
--- a/public/marketplace/status.html
+++ b/public/marketplace/status.html
@@ -45,6 +45,18 @@
       <div class="checks" id="checks"></div>
     </section>
 
+    <section class="mp-section">
+      <h2>Deltas (rolling window)</h2>
+      <div class="sub">Change since 5m / 30m / 60m / 24h ago — populated by pm2 service <code>wallco-mp-watcher</code>.</div>
+      <div class="mp-status-grid" id="deltas"></div>
+    </section>
+
+    <section class="mp-section">
+      <h2>Recent events</h2>
+      <div class="sub">Append-only feed of meaningful changes (new designer, new pattern, new licensing inquiry, watcher startup, etc.).</div>
+      <div class="latest" id="events"></div>
+    </section>
+
     <section class="mp-section">
       <h2>Latest activity</h2>
       <div class="sub">Most-recent designer signups, patterns, commission events, and points earned.</div>
@@ -184,10 +196,70 @@ async function refreshLatest() {
   }
 }
 
+async function refreshDeltas() {
+  try {
+    const w = await fetch('/api/marketplace/watcher').then(r => r.json());
+    const grid = document.getElementById('deltas');
+    if (!w.deltas) { grid.innerHTML = '<div class="sub">Watcher warming up… give it 60s after pm2 start.</div>'; return; }
+    grid.innerHTML = '';
+    const KEYS = [
+      ['designers', 'Designers'],
+      ['patterns',  'Patterns'],
+      ['collections', 'Collections'],
+      ['commission_entries', 'Commission events'],
+      ['open_inquiries', 'Licensing inquiries'],
+      ['unclaimed', 'Claim requests'],
+    ];
+    KEYS.forEach(([k,label]) => {
+      const card = document.createElement('div'); card.className = 'mp-stat';
+      const d5  = w.deltas['5m'][k]  || 0;
+      const d30 = w.deltas['30m'][k] || 0;
+      const d60 = w.deltas['60m'][k] || 0;
+      const d24 = w.deltas['24h'][k] || 0;
+      const sign = (n) => n > 0 ? ('+' + n) : (n < 0 ? String(n) : '0');
+      const color = (n) => n > 0 ? 'color:#0a7' : (n < 0 ? 'color:#c33' : 'color:#aaa');
+      card.innerHTML = `<div class="label">${label}</div>
+        <div style="display:flex;gap:8px;font-size:14px;font-weight:600;margin-top:4px;flex-wrap:wrap">
+          <span style="${color(d5)}">5m ${sign(d5)}</span>
+          <span style="${color(d30)}">30m ${sign(d30)}</span>
+          <span style="${color(d60)}">1h ${sign(d60)}</span>
+          <span style="${color(d24)}">24h ${sign(d24)}</span>
+        </div>`;
+      grid.appendChild(card);
+    });
+  } catch (err) {
+    document.getElementById('deltas').innerHTML = `<div class="sub">watcher offline · ${err.message}</div>`;
+  }
+}
+
+async function refreshEvents() {
+  try {
+    const j = await fetch('/api/marketplace/events?limit=20').then(r => r.json());
+    const out = document.getElementById('events');
+    out.innerHTML = '';
+    (j.events || []).forEach(e => {
+      const row = document.createElement('div'); row.className = 'row-item';
+      const t = new Date(e.ts).toLocaleString();
+      let summary = '';
+      if (e.type === 'startup') summary = `🟢 watcher online (${e.host}:${e.port})`;
+      else if (e.type === 'delta') summary = `+${e.change} ${e.label} (total ${e.total})`;
+      else if (e.type === 'poll_fail') summary = `❌ poll failed: ${e.error}`;
+      else summary = `${e.type}: ${JSON.stringify(e).slice(0,140)}`;
+      row.innerHTML = `<div style="flex:1"><strong>${summary}</strong><div class="meta-r">${t}</div></div>`;
+      out.appendChild(row);
+    });
+    if (!j.events || !j.events.length) out.innerHTML = '<div class="sub">No events yet.</div>';
+  } catch (err) {
+    document.getElementById('events').innerHTML = `<div class="sub">${err.message}</div>`;
+  }
+}
+
 let firstRun = true;
 async function tick() {
   if (firstRun) { logLine('▶ status board live, polling every 4s', 'ok'); firstRun = false; }
   await refreshStats();
+  await refreshDeltas();
+  await refreshEvents();
   await refreshLatest();
   if (Math.random() < 0.25) await refreshChecks(); // re-smoke every ~16s
 }
diff --git a/sql/200_dig_auto_assign.sql b/sql/200_dig_auto_assign.sql
new file mode 100644
index 0000000..0b75f8d
--- /dev/null
+++ b/sql/200_dig_auto_assign.sql
@@ -0,0 +1,26 @@
+-- 200_dig_auto_assign.sql
+-- Every NEW row in spoon_all_designs gets a DW Original Number (DIG_AI_<id>)
+-- the moment its id is generated. Existing rows without a dig_number are
+-- backfilled in one shot. Matches the namespace from /api/design/:id/ai-designer
+-- (`DIG_AI_${String(id).padStart(6,'0')}`) so the two paths are consistent.
+
+CREATE OR REPLACE FUNCTION fn_spoon_all_designs_assign_dig()
+RETURNS trigger AS $$
+BEGIN
+  IF NEW.dig_number IS NULL OR NEW.dig_number = '' THEN
+    NEW.dig_number := 'DIG_AI_' || lpad(NEW.id::text, 6, '0');
+  END IF;
+  RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS trg_spoon_all_designs_assign_dig ON spoon_all_designs;
+CREATE TRIGGER trg_spoon_all_designs_assign_dig
+  BEFORE INSERT ON spoon_all_designs
+  FOR EACH ROW
+  EXECUTE FUNCTION fn_spoon_all_designs_assign_dig();
+
+-- Backfill every existing row that still has NULL dig_number.
+UPDATE spoon_all_designs
+   SET dig_number = 'DIG_AI_' || lpad(id::text, 6, '0')
+ WHERE dig_number IS NULL OR dig_number = '';
diff --git a/src/marketplace/ai.js b/src/marketplace/ai.js
index fc73dce..9259240 100644
--- a/src/marketplace/ai.js
+++ b/src/marketplace/ai.js
@@ -1,47 +1,513 @@
-// AI stub endpoints — deterministic mock JSON, ready to swap for Gemini/Ollama later.
-// All return-shapes match what dashboards / pattern-detail pages will consume.
-
-const PALETTES = [
-  { name: 'Original Artist Palette', hexPalette: ['#2C2A2D','#C9B996','#7A6A5E','#F2EAD8'], description: 'The designer’s primary colourway, balanced for daylight.' },
-  { name: 'Warm Clay',     hexPalette: ['#B46A55','#E8C7A9','#6B4B3E','#F4EFE8'], description: 'Earthy reds and creamy plaster, hospitality-ready.' },
-  { name: 'Moss & Linen',  hexPalette: ['#69785A','#D7D0B8','#2F3A2D','#F7F4EA'], description: 'Quiet luxury greens with a linen ground.' },
-  { name: 'Ink Blue',      hexPalette: ['#1F2E46','#7E9AB8','#E9EDF2','#111827'], description: 'Cinematic indigo for dining and library settings.' },
-  { name: 'Rose Plaster',  hexPalette: ['#C98986','#F0D4CF','#80514F','#FFF8F5'], description: 'Soft rose for primary suites and dressing rooms.' },
-  { name: 'Gold Smoke',    hexPalette: ['#BFA76A','#E6DDC5','#56514A','#151515'], description: 'High-drama gold on near-black, gallery wall mood.' },
+// AI endpoints — Gemini-powered colorway generation, plus stubs for the other AI helpers.
+//
+// /api/marketplace/ai/generate-colorways
+//   Real Gemini 2.5 Flash Image (recolor mode) call against the pattern's
+//   original_image_url. Produces 6 photo-realistic colorway variants, persists
+//   each into mp_pattern_colorways with ai_generated=true, and writes the PNG
+//   into public/marketplace/uploads/. Every call is logged via cost-tracker.
+//
+//   Body (one of):
+//     { patternId: <bigint> }
+//     { patternSlug: "wildflower-reverie-astrid-mauve" }
+//     { imageUrl: "/designs/img/foo.png" }   // no DB persist, returns shape only
+//
+//   Optional:
+//     { mock: true }     // skip Gemini, return placeholder shape (for tests / dev)
+//     { skipPersist: true } // call Gemini but don't insert mp_pattern_colorways rows
+//
+//   Response:
+//     { status: "ok"|"mock", colorways: [{ id, name, image_url, hex_palette, description, ai_generated }] }
+
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const os = require('os');
+const { spawnSync } = require('child_process');
+const { q, one } = require('./db');
+const { shortToken } = require('./util');
+const { ollamaChat, extractJson, cleanStringArray, safePromptField } = require('./ollama');
+
+const UPLOAD_DIR = path.join(__dirname, '..', '..', 'public', 'marketplace', 'uploads');
+const IMG_DIR    = path.join(__dirname, '..', '..', 'data', 'generated');
+fs.mkdirSync(UPLOAD_DIR, { recursive: true });
+
+// ── 6 colorway directions used for the recolor prompts. Kept stable so dashboards
+// can rely on the names appearing in order. Hex_palette is an editorial best-guess
+// and gets overwritten with the post-generation palette extraction when available.
+const COLORWAYS = [
+  { name: 'Warm Clay',     hexPalette: ['#B46A55','#E8C7A9','#6B4B3E','#F4EFE8'],
+    description: 'Earthy reds and creamy plaster, hospitality-ready.',
+    prompt: 'Recolor everything into the WARM CLAY family — earthy terracotta red motif on a soft plaster cream ground, with deep cocoa-brown shadow accents. Hospitality-ready, sun-warmed palette.' },
+  { name: 'Moss & Linen',  hexPalette: ['#69785A','#D7D0B8','#2F3A2D','#F7F4EA'],
+    description: 'Quiet luxury greens with a linen ground.',
+    prompt: 'Recolor everything into the MOSS & LINEN family — sage-to-forest green motif on a warm linen ground, with deepest moss shadow accents. Quiet luxury, library mood.' },
+  { name: 'Ink Blue',      hexPalette: ['#1F2E46','#7E9AB8','#E9EDF2','#111827'],
+    description: 'Cinematic indigo for dining and library settings.',
+    prompt: 'Recolor everything into the INK BLUE family — deep navy / indigo motif on a soft fog-grey ground, with slate-grey midtones. Cinematic dining-room mood.' },
+  { name: 'Rose Plaster',  hexPalette: ['#C98986','#F0D4CF','#80514F','#FFF8F5'],
+    description: 'Soft rose for primary suites and dressing rooms.',
+    prompt: 'Recolor everything into the ROSE PLASTER family — dusty rose motif on a near-white plaster ground, with deepest mauve shadow accents. Soft, romantic, primary-suite palette.' },
+  { name: 'Gold Smoke',    hexPalette: ['#BFA76A','#E6DDC5','#56514A','#151515'],
+    description: 'High-drama gold on near-black, gallery wall mood.',
+    prompt: 'Recolor everything into the GOLD SMOKE family — antique-gold motif on a near-black charcoal ground, with subtle warm-bronze midtones and a hint of metallic shimmer. Gallery-wall drama.' },
+  { name: 'Pearl Frost',   hexPalette: ['#D8D5CB','#F7F4EE','#8E867A','#2F2A25'],
+    description: 'Pale frost neutrals, gallery-clean and contract-ready.',
+    prompt: 'Recolor everything into the PEARL FROST family — soft pearl-grey motif on an ivory-frost ground, with cool taupe midtones. Contract-clean, minimal-luxury palette.' },
 ];
 
+const SHARED_TAIL = [
+  'Preserve the pattern shapes, scale, and composition exactly — recolor only.',
+  'Seamless tile, no edge artifacts, no signature, no watermark, no text.',
+  'High detail, archival quality, photo-realistic wallcovering aesthetic, no cartoon style.',
+].join(' ');
+
+// Resolve a pattern image_url ('/designs/img/x.png' or 'http(s)://…' or
+// '/marketplace/uploads/x.jpg') to absolute disk path or remote URL.
+function resolveSource(imageUrl) {
+  if (!imageUrl) return null;
+  if (/^https?:\/\//i.test(imageUrl)) return { kind: 'url', value: imageUrl };
+  if (imageUrl.startsWith('/designs/img/')) {
+    return { kind: 'file', value: path.join(IMG_DIR, imageUrl.replace('/designs/img/', '')) };
+  }
+  if (imageUrl.startsWith('/marketplace/uploads/')) {
+    return { kind: 'file', value: path.join(UPLOAD_DIR, imageUrl.replace('/marketplace/uploads/', '')) };
+  }
+  // Anything else — try as a relative path under project root.
+  return { kind: 'file', value: path.join(__dirname, '..', '..', imageUrl.replace(/^\/+/, '')) };
+}
+
+async function loadSourceBuffer(src) {
+  if (src.kind === 'file') {
+    if (!fs.existsSync(src.value)) throw new Error(`source file missing: ${src.value}`);
+    return fs.readFileSync(src.value);
+  }
+  // remote
+  const r = await fetch(src.value);
+  if (!r.ok) throw new Error(`source fetch ${r.status} for ${src.value}`);
+  const buf = Buffer.from(await r.arrayBuffer());
+  return buf;
+}
+
+function detectMime(buf, hintUrl) {
+  if (buf && buf.length >= 4) {
+    if (buf[0] === 0xff && buf[1] === 0xd8) return 'image/jpeg';
+    if (buf[0] === 0x89 && buf[1] === 0x50) return 'image/png';
+    if (buf[0] === 0x47 && buf[1] === 0x49) return 'image/gif';
+    if (buf[0] === 0x52 && buf[1] === 0x49 && buf[8] === 0x57 && buf[9] === 0x45) return 'image/webp';
+  }
+  const ext = (hintUrl || '').toLowerCase();
+  if (ext.endsWith('.png'))  return 'image/png';
+  if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) return 'image/jpeg';
+  if (ext.endsWith('.webp')) return 'image/webp';
+  return 'image/jpeg';
+}
+
+async function callGemini({ srcB64, mime, prompt, app, note }) {
+  const KEY = process.env.GEMINI_API_KEY;
+  if (!KEY) throw new Error('GEMINI_API_KEY missing');
+  const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent?key=${KEY}`;
+  const body = {
+    contents: [{
+      parts: [
+        { inline_data: { mime_type: mime, data: srcB64 } },
+        { text: prompt + ' ' + SHARED_TAIL },
+      ],
+    }],
+    generationConfig: { responseModalities: ['IMAGE'] },
+  };
+  const r = await fetch(url, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(body),
+  });
+  if (!r.ok) throw new Error(`gemini ${r.status}: ${(await r.text()).slice(0, 300)}`);
+  const j = await r.json();
+  try {
+    const { logGemini } = require(path.join(os.homedir(), '.claude/skills/cost-tracker/scripts/log-gemini.js'));
+    logGemini(j, { app: app || 'wallco-ai', note: note || 'marketplace-colorway', model: 'gemini-2.5-flash-image' });
+  } catch (_) {}
+  const part = j.candidates?.[0]?.content?.parts?.find(p => p.inline_data || p.inlineData);
+  const data = part?.inline_data?.data || part?.inlineData?.data;
+  if (!data) {
+    const text = j.candidates?.[0]?.content?.parts?.find(p => p.text)?.text;
+    throw new Error(`gemini returned no image (${j.candidates?.[0]?.finishReason || 'unknown'}): ${(text || '').slice(0, 200)}`);
+  }
+  return Buffer.from(data, 'base64');
+}
+
+// Best-effort palette extraction via Python/PIL — falls back to the editorial
+// hex palette if Python or PIL aren't available.
+function extractPalette(filePath, fallback) {
+  try {
+    const r = spawnSync('python3', ['-c', `
+from PIL import Image
+from collections import Counter
+import json, sys
+img = Image.open(sys.argv[1]).convert('RGB')
+img.thumbnail((300, 300))
+pal = img.quantize(colors=5, method=Image.Quantize.MEDIANCUT).convert('RGB')
+pixels = list(pal.getdata())
+cnt = Counter(pixels)
+print(json.dumps(['#{:02x}{:02x}{:02x}'.format(*rgb) for rgb,_ in cnt.most_common(5)]))
+`, filePath], { encoding: 'utf8', timeout: 10_000 });
+    if (r.status === 0) {
+      const arr = JSON.parse(r.stdout.trim());
+      if (Array.isArray(arr) && arr.length) return arr;
+    }
+  } catch (_) {}
+  return fallback || [];
+}
+
+async function generateOneColorway({ patternId, srcB64, mime, def, persist }) {
+  const buf = await callGemini({
+    srcB64, mime, prompt: def.prompt,
+    app: 'wallco-ai',
+    note: `marketplace-colorway pattern=${patternId || 'n/a'} ${def.name}`,
+  });
+  const filename = `colorway-${Date.now()}-${shortToken(5)}.png`;
+  const outPath = path.join(UPLOAD_DIR, filename);
+  fs.writeFileSync(outPath, buf);
+  const rel = '/marketplace/uploads/' + filename;
+  const palette = extractPalette(outPath, def.hexPalette);
+
+  let row = null;
+  if (persist && patternId) {
+    row = await one(
+      `INSERT INTO mp_pattern_colorways (pattern_id, name, image_url, hex_palette, is_primary, ai_generated)
+       VALUES ($1,$2,$3,$4::jsonb,false,true)
+       RETURNING id, pattern_id, name, image_url, hex_palette, is_primary, ai_generated`,
+      [patternId, def.name, rel, JSON.stringify(palette)]
+    );
+  }
+  return {
+    id: row?.id || null,
+    name: def.name,
+    image_url: rel,
+    hex_palette: palette,
+    description: def.description,
+    ai_generated: true,
+  };
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// qwen3:14b-powered tagger + copywriter
+// Mac1 Ollama at 192.168.1.133:11434 (tailscale bridge 100.65.187.120:11435).
+// Mirrors the calling pattern from src/review.js.
+// ─────────────────────────────────────────────────────────────────────────────
+
+const TAG_LIMITS = {
+  styleTags:  { maxItems: 6, maxLen: 40 },
+  roomTags:   { maxItems: 6, maxLen: 40 },
+  colorTags:  { maxItems: 8, maxLen: 40 },
+  motifTags:  { maxItems: 8, maxLen: 40 },
+  moodTags:   { maxItems: 6, maxLen: 40 },
+};
+
+// Fallback used when Ollama is unreachable so dashboards keep working in
+// degraded mode. Identical shape to the live response.
+const MOCK_TAG_RESPONSE = {
+  status: 'mock',
+  styleTags: ['Botanical', 'Maximalist', 'Romantic'],
+  roomTags: ['Dining Room', 'Powder Room', 'Boutique Hotel'],
+  colorTags: ['Warm Neutral', 'Olive', 'Plaster'],
+  motifTags: ['Foliage', 'Vine', 'Bird'],
+  moodTags: ['Quiet Luxury', 'Editorial'],
+  scale: 'medium-large',
+  mood: 'Soulful, layered, painterly — reads better at 5+ feet of viewing distance.',
+};
+
+// Look up an mp_patterns row by id or slug. Returns null when neither resolves.
+async function resolvePattern({ patternId, patternSlug }) {
+  if (patternId != null) {
+    const id = parseInt(patternId, 10);
+    if (Number.isFinite(id) && id > 0) {
+      return await one(
+        `SELECT id, title, slug, description, long_description, original_image_url,
+                style_tags, room_tags, color_tags, motif_tags, mood_tags, repeat_type, scale_notes
+           FROM mp_patterns WHERE id=$1`,
+        [id]
+      );
+    }
+  }
+  if (patternSlug && typeof patternSlug === 'string') {
+    return await one(
+      `SELECT id, title, slug, description, long_description, original_image_url,
+              style_tags, room_tags, color_tags, motif_tags, mood_tags, repeat_type, scale_notes
+         FROM mp_patterns WHERE slug=$1`,
+      [patternSlug.slice(0, 200)]
+    );
+  }
+  return null;
+}
+
+function tagSystemPrompt(pattern, imageUrl) {
+  const title = safePromptField(pattern?.title || 'Untitled Pattern', 120);
+  const repeat = safePromptField(pattern?.repeat_type || 'unknown', 40);
+  const scale = safePromptField(pattern?.scale_notes || '', 200);
+  const img = safePromptField(imageUrl || pattern?.original_image_url || '', 300);
+  return `You are a senior interior-design merchandiser tagging a wallcovering pattern for an online marketplace.
+
+Pattern title: "${title}"
+Repeat type:   ${repeat}
+Scale notes:   ${scale || '(none)'}
+Image URL:     ${img || '(none — tag from title + notes only)'}
+
+Return STRICT JSON ONLY (no preamble, no markdown fences). Exact shape:
+{
+  "styleTags":  [3–6 strings from {Botanical, Geometric, Damask, Toile, Chinoiserie, Floral, Stripe, Abstract, Paisley, Mural, Trellis, Grasscloth, Modern, Traditional, Mid-Century, Maximalist, Minimalist, Art Deco, Romantic, Brutalist}],
+  "roomTags":   [3–6 strings from {Dining Room, Powder Room, Living Room, Bedroom, Primary Suite, Library, Foyer, Boutique Hotel, Restaurant, Spa, Corporate Lobby, Children's Room, Office}],
+  "colorTags":  [3–8 short color descriptors (e.g., "Warm Neutral", "Olive", "Plaster", "Deep Indigo", "Brass")],
+  "motifTags":  [3–8 concrete motifs in the image (e.g., "Foliage", "Vine", "Bird", "Crane", "Cherry Blossom")],
+  "moodTags":   [2–6 mood words (e.g., "Quiet Luxury", "Editorial", "Playful", "Restrained", "Theatrical")],
+  "scale":      one of {"small", "medium", "medium-large", "large", "mural"},
+  "mood":       one sentence (≤24 words) on overall mood + viewing distance.
+}
+
+Be specific. Never use generic placeholder values like "tag1". Never include keys not in the schema.`;
+}
+
+async function callTagger(pattern, imageUrl) {
+  const sys = tagSystemPrompt(pattern, imageUrl);
+  const raw = await ollamaChat({
+    system: sys,
+    message: 'Tag this pattern. Return the JSON object only.',
+    temperature: 0.3,
+    timeoutMs: 90_000,
+  });
+  const parsed = extractJson(raw);
+  if (!parsed) throw new Error('qwen3 returned non-JSON');
+  return {
+    styleTags: cleanStringArray(parsed.styleTags, TAG_LIMITS.styleTags),
+    roomTags:  cleanStringArray(parsed.roomTags,  TAG_LIMITS.roomTags),
+    colorTags: cleanStringArray(parsed.colorTags, TAG_LIMITS.colorTags),
+    motifTags: cleanStringArray(parsed.motifTags, TAG_LIMITS.motifTags),
+    moodTags:  cleanStringArray(parsed.moodTags,  TAG_LIMITS.moodTags),
+    scale:     typeof parsed.scale === 'string' ? parsed.scale.trim().toLowerCase().slice(0, 24) : '',
+    mood:      typeof parsed.mood === 'string' ? parsed.mood.trim().slice(0, 280) : '',
+  };
+}
+
+async function persistTags(patternId, tags) {
+  await q(
+    `UPDATE mp_patterns
+        SET style_tags = $2, room_tags  = $3, color_tags = $4,
+            motif_tags = $5, mood_tags  = $6, updated_at = now()
+      WHERE id = $1`,
+    [
+      patternId,
+      tags.styleTags || [], tags.roomTags || [], tags.colorTags || [],
+      tags.motifTags || [], tags.moodTags || [],
+    ]
+  );
+}
+
+function descriptionSystemPrompt(pattern, titleHint) {
+  const title = safePromptField(titleHint || pattern?.title || 'Untitled Pattern', 120);
+  const styleTags = Array.isArray(pattern?.style_tags) ? pattern.style_tags.slice(0, 6).join(', ') : '';
+  const roomTags  = Array.isArray(pattern?.room_tags) ? pattern.room_tags.slice(0, 6).join(', ') : '';
+  const colorTags = Array.isArray(pattern?.color_tags) ? pattern.color_tags.slice(0, 8).join(', ') : '';
+  const motifTags = Array.isArray(pattern?.motif_tags) ? pattern.motif_tags.slice(0, 8).join(', ') : '';
+  return `You are a senior copywriter for Designer Wallcoverings / wallco.ai's designer marketplace.
+
+Pattern title hint: "${title}"
+Style tags:  ${safePromptField(styleTags) || '(none)'}
+Room tags:   ${safePromptField(roomTags) || '(none)'}
+Color tags:  ${safePromptField(colorTags) || '(none)'}
+Motif tags:  ${safePromptField(motifTags) || '(none)'}
+
+Return STRICT JSON ONLY (no preamble, no markdown fences). Exact shape:
+{
+  "titleSuggestion": "Refined evocative title (2–5 words; you may keep the hint if it's already strong)",
+  "shortDescription": "One-line description ≤120 chars, sentence case, no emoji.",
+  "longDescription":  "2–4 sentences. Describe the drawing, color family, mood, and one concrete room recommendation. No clichés ('elevate your space'). Mention substrates available (non-woven, peel-and-stick, Type II contract) once.",
+  "seoTitle":          "≤60 chars, ends with '| Wallco.ai Designer Wallcoverings'",
+  "seoDescription":    "≤155 chars meta description, ends with a complete sentence."
+}
+
+Be concrete and brand-appropriate. Never use placeholder values. Never include keys not in the schema.`;
+}
+
+async function callCopywriter(pattern, titleHint) {
+  const sys = descriptionSystemPrompt(pattern, titleHint);
+  const raw = await ollamaChat({
+    system: sys,
+    message: 'Write the copy. Return the JSON object only.',
+    temperature: 0.55,
+    timeoutMs: 90_000,
+  });
+  const parsed = extractJson(raw);
+  if (!parsed) throw new Error('qwen3 returned non-JSON');
+  const fallbackTitle = (titleHint || pattern?.title || 'Untitled Pattern').toString();
+  return {
+    titleSuggestion:  String(parsed.titleSuggestion  || fallbackTitle).trim().slice(0, 120),
+    shortDescription: String(parsed.shortDescription || '').trim().slice(0, 240),
+    longDescription:  String(parsed.longDescription  || '').trim().slice(0, 1400),
+    seoTitle:         String(parsed.seoTitle         || '').trim().slice(0, 80),
+    seoDescription:   String(parsed.seoDescription   || '').trim().slice(0, 200),
+  };
+}
+
+async function persistCopy(patternId, copy) {
+  await q(
+    `UPDATE mp_patterns
+        SET description      = COALESCE(NULLIF($2,''), description),
+            long_description = COALESCE(NULLIF($3,''), long_description),
+            updated_at       = now()
+      WHERE id = $1`,
+    [patternId, copy.shortDescription || '', copy.longDescription || '']
+  );
+}
+
 function mount(app) {
-  app.post('/api/marketplace/ai/generate-colorways', (req, res) => {
+  app.post('/api/marketplace/ai/generate-colorways', async (req, res) => {
     const body = req.body || {};
     if (!body.patternId && !body.imageUrl && !body.patternSlug) {
       return res.status(400).json({ error: 'patternId, patternSlug, or imageUrl required' });
     }
-    res.json({ status: 'mock', message: 'AI colorway placeholder (wire to Gemini/Ollama qwen3:14b)', colorways: PALETTES });
+
+    // Mock mode — preserve the legacy shape so dashboards & tests can run without burning Gemini.
+    if (body.mock === true || body.mock === 'true') {
+      return res.json({
+        status: 'mock',
+        message: 'AI colorway placeholder (mock=true)',
+        colorways: COLORWAYS.map(c => ({
+          name: c.name, hexPalette: c.hexPalette, description: c.description,
+          image_url: null, ai_generated: false,
+        })),
+      });
+    }
+
+    if (!process.env.GEMINI_API_KEY) {
+      return res.status(500).json({ error: 'GEMINI_API_KEY not configured' });
+    }
+
+    try {
+      // Resolve pattern + source image
+      let pattern = null;
+      let imageUrl = body.imageUrl;
+      if (body.patternId) {
+        pattern = await one(`SELECT id, slug, original_image_url FROM mp_patterns WHERE id=$1`, [parseInt(body.patternId, 10)]);
+      } else if (body.patternSlug) {
+        pattern = await one(`SELECT id, slug, original_image_url FROM mp_patterns WHERE slug=$1`, [body.patternSlug]);
+      }
+      if (pattern) imageUrl = imageUrl || pattern.original_image_url;
+      if (!imageUrl) return res.status(404).json({ error: 'pattern not found or has no original_image_url' });
+
+      const src = resolveSource(imageUrl);
+      const buf = await loadSourceBuffer(src);
+      const mime = detectMime(buf, imageUrl);
+      const srcB64 = buf.toString('base64');
+
+      const persist = !!pattern && body.skipPersist !== true && body.skipPersist !== 'true';
+
+      // Run all 6 calls in parallel — each takes ~5-15s, so this lands ~1×latency
+      // instead of 6×. Failures are captured per-colorway so a single Gemini hiccup
+      // doesn't kill the whole batch.
+      const results = await Promise.allSettled(
+        COLORWAYS.map(def => generateOneColorway({
+          patternId: pattern?.id || null,
+          srcB64, mime, def, persist,
+        }))
+      );
+      const colorways = results
+        .filter(r => r.status === 'fulfilled')
+        .map(r => r.value);
+      const errors = results
+        .filter(r => r.status === 'rejected')
+        .map(r => String(r.reason?.message || r.reason).slice(0, 200));
+
+      if (!colorways.length) {
+        return res.status(502).json({ error: 'all gemini calls failed', details: errors });
+      }
+      res.json({
+        status: 'ok',
+        pattern: pattern ? { id: pattern.id, slug: pattern.slug } : null,
+        colorways,
+        errors: errors.length ? errors : undefined,
+      });
+    } catch (err) {
+      console.error('[ai/generate-colorways]', err);
+      res.status(500).json({ error: err.message });
+    }
   });
 
-  app.post('/api/marketplace/ai/tag-pattern', (req, res) => {
-    res.json({
-      status: 'mock',
-      styleTags: ['Botanical', 'Maximalist', 'Romantic'],
-      roomTags: ['Dining Room', 'Powder Room', 'Boutique Hotel'],
-      colorTags: ['Warm Neutral', 'Olive', 'Plaster'],
-      motifTags: ['Foliage', 'Vine', 'Bird'],
-      moodTags: ['Quiet Luxury', 'Editorial'],
-      scale: 'medium-large',
-      mood: 'Soulful, layered, painterly — reads better at 5+ feet of viewing distance.'
-    });
+  // Tag a pattern via qwen3:14b. Body: { patternId? | patternSlug? | imageUrl? }.
+  //   - patternId/Slug resolves an mp_patterns row → tag with context + persist
+  //   - imageUrl alone → tag without persistence
+  //   - empty body → return MOCK_TAG_RESPONSE (legacy smoke-test contract)
+  //   - Ollama unreachable → fall back to MOCK_TAG_RESPONSE + fallback_reason
+  app.post('/api/marketplace/ai/tag-pattern', async (req, res) => {
+    const body = req.body || {};
+    let pattern = null;
+    try {
+      pattern = await resolvePattern(body);
+    } catch (err) {
+      console.error('[ai/tag-pattern] resolvePattern failed:', err.message);
+    }
+    // Preserve the legacy contract: empty body still returns 200 with mock shape.
+    if (!pattern && !body.imageUrl && !body.patternId && !body.patternSlug) {
+      return res.json(MOCK_TAG_RESPONSE);
+    }
+    try {
+      const tags = await callTagger(pattern, body.imageUrl);
+      let persisted = false;
+      if (pattern && pattern.id) {
+        try { await persistTags(pattern.id, tags); persisted = true; }
+        catch (err) { console.error('[ai/tag-pattern] persistTags failed:', err.message); }
+      }
+      res.json({
+        status: 'live',
+        model: 'qwen3:14b',
+        pattern_id: pattern?.id || null,
+        pattern_slug: pattern?.slug || null,
+        persisted,
+        ...tags,
+      });
+    } catch (err) {
+      console.warn('[ai/tag-pattern] qwen3 unreachable, falling back to mock:', err.message);
+      res.json({ ...MOCK_TAG_RESPONSE, fallback_reason: err.message.slice(0, 200) });
+    }
   });
 
-  app.post('/api/marketplace/ai/write-pattern-description', (req, res) => {
-    const title = (req.body && req.body.title) || 'Untitled Pattern';
-    res.json({
-      status: 'mock',
-      titleSuggestion: title,
-      shortDescription: `${title} — a layered, large-scale wallcovering with painterly motifs.`,
-      longDescription: `${title} reads as a quiet-luxury anchor for a dining room or boutique hotel suite. The drawing is layered without crowding, and the colour family is engineered for warm artificial light and skylit daylight alike. Printed by Designer Wallcoverings, available in non-woven, peel-and-stick, and Type II contract substrates.`,
-      seoTitle: `${title} | Wallco.ai Designer Wallcoverings`,
-      seoDescription: `Shop ${title} — designer-led wallcovering by an independent Wallco creator. Sample first, license, or specify for trade.`
-    });
+  // Write copy for a pattern via qwen3:14b. Body: { patternId? | patternSlug? | title? }.
+  // Mirrors tag-pattern: persist on row match, mock fallback on outage, preserves
+  // the legacy contract for empty bodies (returns shape, status:"mock").
+  app.post('/api/marketplace/ai/write-pattern-description', async (req, res) => {
+    const body = req.body || {};
+    let pattern = null;
+    try {
+      pattern = await resolvePattern(body);
+    } catch (err) {
+      console.error('[ai/write-pattern-description] resolvePattern failed:', err.message);
+    }
+    const titleHint = (body.title && String(body.title).trim()) || pattern?.title || 'Untitled Pattern';
+    try {
+      const copy = await callCopywriter(pattern, titleHint);
+      let persisted = false;
+      if (pattern && pattern.id) {
+        try { await persistCopy(pattern.id, copy); persisted = true; }
+        catch (err) { console.error('[ai/write-pattern-description] persistCopy failed:', err.message); }
+      }
+      res.json({
+        status: 'live',
+        model: 'qwen3:14b',
+        pattern_id: pattern?.id || null,
+        pattern_slug: pattern?.slug || null,
+        persisted,
+        ...copy,
+      });
+    } catch (err) {
+      console.warn('[ai/write-pattern-description] qwen3 unreachable, falling back to mock:', err.message);
+      res.json({
+        status: 'mock',
+        titleSuggestion: titleHint,
+        shortDescription: `${titleHint} — a layered, large-scale wallcovering with painterly motifs.`,
+        longDescription: `${titleHint} reads as a quiet-luxury anchor for a dining room or boutique hotel suite. The drawing is layered without crowding, and the colour family is engineered for warm artificial light and skylit daylight alike. Printed by Designer Wallcoverings, available in non-woven, peel-and-stick, and Type II contract substrates.`,
+        seoTitle: `${titleHint} | Wallco.ai Designer Wallcoverings`,
+        seoDescription: `Shop ${titleHint} — designer-led wallcovering by an independent Wallco creator. Sample first, license, or specify for trade.`,
+        fallback_reason: err.message.slice(0, 200),
+      });
+    }
   });
 
   app.post('/api/marketplace/ai/create-room-mockup', (req, res) => {
@@ -54,4 +520,14 @@ function mount(app) {
   });
 }
 
-module.exports = { mount, PALETTES };
+module.exports = {
+  mount,
+  COLORWAYS,
+  PALETTES: COLORWAYS,
+  // Exported for tests + admin tooling.
+  resolvePattern,
+  callTagger,
+  callCopywriter,
+  persistTags,
+  persistCopy,
+};
diff --git a/src/marketplace/challenges.js b/src/marketplace/challenges.js
new file mode 100644
index 0000000..6fa4ddb
--- /dev/null
+++ b/src/marketplace/challenges.js
@@ -0,0 +1,279 @@
+// Wallco Marketplace — monthly challenges + leaderboard.
+//
+// Public surface:
+//   getLeaderboard({ metric, limit, since, until }) → [{ designer_id, slug, display_name, score, ... }]
+//   getActiveChallenges()                            → rows from mp_challenges where status=active
+//   closeChallenge(challengeId)                      → idempotent; auto-awards CHALLENGE_WIN
+//   createChallenge({ title, description, starts_at, ends_at, metric })
+//
+// Metric semantics:
+//   points       → mp_designer_profiles.points (lifetime). Windowed via mp_points_ledger.created_at.
+//   sales        → COUNT(DISTINCT mp_orders.id) on items where designer matches; windowed by mp_orders.created_at.
+//   saves        → COUNT(*) from mp_project_saves joined through mp_patterns.designer_id (windowed by saves.created_at).
+//   collections  → COUNT(*) from mp_collections where status='published' (windowed by created_at).
+//
+// All queries filter to designers with status='approved' so unfunded / pending profiles
+// don't appear on a public leaderboard.
+
+const { q, one, many } = require('./db');
+const gam = require('./gamification');
+
+const VALID_METRICS = ['points', 'sales', 'saves', 'collections'];
+
+function assertMetric(metric) {
+  if (!VALID_METRICS.includes(metric)) {
+    throw new Error('Invalid metric: ' + metric + ' (expected one of ' + VALID_METRICS.join(', ') + ')');
+  }
+}
+
+// Build the leaderboard SQL for a given metric. Windowed metrics accept since/until
+// (timestamps). When no window is supplied, "points" returns lifetime totals from the
+// profile, and the count-based metrics include all history.
+function buildLeaderboardQuery(metric, { since = null, until = null, limit = 50 } = {}) {
+  assertMetric(metric);
+  const params = [];
+  const lim = Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200);
+
+  // helper to push a param and return its $N placeholder
+  function p(v) { params.push(v); return '$' + params.length; }
+
+  let sql;
+  if (metric === 'points') {
+    if (since || until) {
+      const onPreds = [];
+      if (since) onPreds.push(`pl.created_at >= ${p(since)}`);
+      if (until) onPreds.push(`pl.created_at < ${p(until)}`);
+      const onSql = onPreds.length ? ' AND ' + onPreds.join(' AND ') : '';
+      sql = `
+        SELECT d.id AS designer_id, d.slug, d.display_name, d.avatar_url, d.level, d.is_founding, d.is_verified,
+               COALESCE(SUM(pl.points), 0)::numeric AS score
+          FROM mp_designer_profiles d
+          LEFT JOIN mp_points_ledger pl ON pl.designer_id = d.id${onSql}
+         WHERE d.status='approved'
+         GROUP BY d.id
+         ORDER BY score DESC, d.display_name ASC
+         LIMIT ${p(lim)}`;
+    } else {
+      sql = `
+        SELECT id AS designer_id, slug, display_name, avatar_url, level, is_founding, is_verified,
+               points::numeric AS score
+          FROM mp_designer_profiles
+         WHERE status='approved'
+         ORDER BY points DESC, display_name ASC
+         LIMIT ${p(lim)}`;
+    }
+  } else if (metric === 'sales') {
+    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
+        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 ')})
+       WHERE d.status='approved'
+       GROUP BY d.id
+       ORDER BY score DESC, d.display_name ASC
+       LIMIT ${p(lim)}`;
+  } else if (metric === 'saves') {
+    const onPreds = [];
+    if (since) onPreds.push(`ps.created_at >= ${p(since)}`);
+    if (until) onPreds.push(`ps.created_at < ${p(until)}`);
+    const onSql = onPreds.length ? ' AND ' + onPreds.join(' AND ') : '';
+    sql = `
+      SELECT d.id AS designer_id, d.slug, d.display_name, d.avatar_url, d.level, d.is_founding, d.is_verified,
+             COUNT(ps.id)::numeric AS score
+        FROM mp_designer_profiles d
+        LEFT JOIN mp_patterns p ON p.designer_id = d.id
+        LEFT JOIN mp_project_saves ps ON ps.pattern_id = p.id${onSql}
+       WHERE d.status='approved'
+       GROUP BY d.id
+       ORDER BY score DESC, d.display_name ASC
+       LIMIT ${p(lim)}`;
+  } else if (metric === 'collections') {
+    const onPreds = ["c.status='published'"];
+    if (since) onPreds.push(`c.created_at >= ${p(since)}`);
+    if (until) onPreds.push(`c.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(c.id)::numeric AS score
+        FROM mp_designer_profiles d
+        LEFT JOIN mp_collections c ON c.designer_id = d.id AND ${onPreds.join(' AND ')}
+       WHERE d.status='approved'
+       GROUP BY d.id
+       ORDER BY score DESC, d.display_name ASC
+       LIMIT ${p(lim)}`;
+  }
+  return { sql, params };
+}
+
+async function getLeaderboard(opts = {}) {
+  const metric = opts.metric || 'points';
+  const { sql, params } = buildLeaderboardQuery(metric, opts);
+  const rows = await many(sql, params);
+  // rank in JS so ties share rank (1,2,2,4)
+  let prevScore = null;
+  let prevRank = 0;
+  return rows.map((r, i) => {
+    const score = Number(r.score || 0);
+    let rank;
+    if (prevScore !== null && score === prevScore) {
+      rank = prevRank;
+    } else {
+      rank = i + 1;
+      prevRank = rank;
+      prevScore = score;
+    }
+    return {
+      rank,
+      designer_id: Number(r.designer_id),
+      slug: r.slug,
+      display_name: r.display_name,
+      avatar_url: r.avatar_url,
+      level: r.level,
+      is_founding: !!r.is_founding,
+      is_verified: !!r.is_verified,
+      score,
+    };
+  });
+}
+
+async function getActiveChallenges() {
+  return many(
+    `SELECT id, title, description, metric, starts_at, ends_at, status
+       FROM mp_challenges
+      WHERE status IN ('scheduled','active')
+        AND ends_at > now()
+      ORDER BY ends_at ASC
+      LIMIT 20`
+  );
+}
+
+async function getRecentChallenges(limit = 10) {
+  return many(
+    `SELECT c.id, c.title, c.description, c.metric, c.starts_at, c.ends_at, c.status,
+            c.winner_designer_id, c.winner_score, c.closed_at,
+            d.slug AS winner_slug, d.display_name AS winner_name, d.avatar_url AS winner_avatar
+       FROM mp_challenges c
+       LEFT JOIN mp_designer_profiles d ON d.id = c.winner_designer_id
+      ORDER BY COALESCE(c.closed_at, c.ends_at) DESC
+      LIMIT $1`,
+    [Math.min(Math.max(limit, 1), 100)]
+  );
+}
+
+async function createChallenge(opts = {}) {
+  const title = String(opts.title || '').trim();
+  const description = opts.description ? String(opts.description) : null;
+  const metric = opts.metric;
+  const starts_at = opts.starts_at ? new Date(opts.starts_at) : null;
+  const ends_at = opts.ends_at ? new Date(opts.ends_at) : null;
+  if (!title) throw new Error('title required');
+  assertMetric(metric);
+  if (!starts_at || isNaN(starts_at)) throw new Error('valid starts_at required');
+  if (!ends_at || isNaN(ends_at)) throw new Error('valid ends_at required');
+  if (ends_at <= starts_at) throw new Error('ends_at must be after starts_at');
+  const status = starts_at <= new Date() ? 'active' : 'scheduled';
+  return one(
+    `INSERT INTO mp_challenges (title, description, metric, starts_at, ends_at, status)
+     VALUES ($1,$2,$3,$4,$5,$6)
+     RETURNING *`,
+    [title, description, metric, starts_at, ends_at, status]
+  );
+}
+
+// Idempotent — if challenge already has status='closed' just returns the existing winner.
+// Otherwise: compute leaderboard windowed to [starts_at, ends_at), populate
+// mp_challenge_entries, award CHALLENGE_WIN to top scorer (only if their score > 0),
+// flip status='closed', return winner row.
+async function closeChallenge(challengeId, opts = {}) {
+  const chal = await one(`SELECT * FROM mp_challenges WHERE id=$1`, [challengeId]);
+  if (!chal) throw new Error('challenge not found');
+  if (chal.status === 'closed') {
+    return { challenge: chal, alreadyClosed: true, winner: chal.winner_designer_id ? { designer_id: chal.winner_designer_id, score: Number(chal.winner_score || 0) } : null };
+  }
+  if (chal.status === 'cancelled') {
+    return { challenge: chal, alreadyClosed: true, winner: null };
+  }
+
+  const rows = await getLeaderboard({
+    metric: chal.metric,
+    since: chal.starts_at,
+    until: chal.ends_at,
+    limit: 200,
+  });
+
+  // Persist entries for posterity
+  if (rows.length) {
+    for (const r of rows) {
+      await q(
+        `INSERT INTO mp_challenge_entries (challenge_id, designer_id, score, rank)
+         VALUES ($1,$2,$3,$4)
+         ON CONFLICT (challenge_id, designer_id) DO UPDATE
+           SET score=EXCLUDED.score, rank=EXCLUDED.rank`,
+        [challengeId, r.designer_id, r.score, r.rank]
+      );
+    }
+  }
+
+  const winner = rows.find((r) => r.score > 0) || null;
+  let awarded = null;
+  if (winner && !opts.skipAward) {
+    try {
+      awarded = await gam.awardPoints(winner.designer_id, 'CHALLENGE_WIN', { notes: `Challenge #${challengeId}: ${chal.title}` });
+    } catch (e) { console.error('[challenge close] award points failed:', e.message); }
+    try {
+      await gam.awardBadge(winner.designer_id, 'TOP_SELLER_MONTH');
+    } catch (e) { /* badge optional / already held */ }
+  }
+
+  const updated = await one(
+    `UPDATE mp_challenges
+        SET status='closed',
+            closed_at=now(),
+            winner_designer_id=$2,
+            winner_score=$3
+      WHERE id=$1
+      RETURNING *`,
+    [challengeId, winner ? winner.designer_id : null, winner ? winner.score : null]
+  );
+
+  return { challenge: updated, winner, awarded, entries: rows.length };
+}
+
+// Sweep — flip status='active' for any scheduled challenge whose window has opened.
+async function activateScheduled() {
+  const r = await q(
+    `UPDATE mp_challenges SET status='active'
+       WHERE status='scheduled' AND starts_at <= now() AND ends_at > now()`
+  );
+  return r.rowCount || 0;
+}
+
+// Sweep — auto-close any active challenge whose window has elapsed.
+async function autoCloseExpired() {
+  const expired = await many(
+    `SELECT id FROM mp_challenges WHERE status IN ('scheduled','active') AND ends_at <= now()`
+  );
+  const closed = [];
+  for (const row of expired) {
+    try {
+      const r = await closeChallenge(row.id);
+      closed.push({ id: row.id, winner_id: r.winner?.designer_id || null });
+    } catch (e) { console.error('[autoCloseExpired]', row.id, e.message); }
+  }
+  return closed;
+}
+
+module.exports = {
+  VALID_METRICS,
+  buildLeaderboardQuery,
+  getLeaderboard,
+  getActiveChallenges,
+  getRecentChallenges,
+  createChallenge,
+  closeChallenge,
+  activateScheduled,
+  autoCloseExpired,
+};
diff --git a/src/marketplace/orders.js b/src/marketplace/orders.js
new file mode 100644
index 0000000..eb433bc
--- /dev/null
+++ b/src/marketplace/orders.js
@@ -0,0 +1,370 @@
+// Marketplace order flow:
+//   - POST /api/marketplace/orders/sample — creates a Shopify draft order tagged 'sample'
+//     for a single marketplace pattern. Returns the invoice URL so the buyer can pay.
+//   - POST /api/marketplace/webhooks/shopify/orders-fulfilled — Shopify webhook ingestor.
+//     Verifies HMAC, parses line-item properties / note_attributes back to mp_pattern_id,
+//     and writes mp_orders + mp_order_items + mp_commission_ledger rows using
+//     buildLedgerEntry from src/marketplace/commissions.js.
+//
+// Attribution model: marketplace patterns are NOT Shopify products (yet) — we create
+// the draft order with a custom line item (no variant_id) and stash pattern_id /
+// designer_id on the line_item.properties array AND on order.note_attributes, so the
+// fulfilled-order webhook can reconstruct attribution from either.
+
+const crypto = require('crypto');
+const { q, one } = require('./db');
+const { safeText, emailOk } = require('./util');
+const { createDraftOrder, isConfigured: shopifyConfigured } = require('./shopify');
+const commissions = require('./commissions');
+
+const SAMPLE_PRICE_DEFAULT = Number(process.env.MP_SAMPLE_PRICE || 5.00);
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Pure helpers (testable without DB / network)
+// ─────────────────────────────────────────────────────────────────────────────
+
+function shipToValid(s) {
+  if (!s || typeof s !== 'object') return null;
+  const name = s.name && String(s.name).trim();
+  const address1 = s.address1 && String(s.address1).trim();
+  const city = s.city && String(s.city).trim();
+  const country = s.country && String(s.country).trim();
+  const zip = s.zip && String(s.zip).trim();
+  if (!name || !address1 || !city || !country || !zip) return null;
+  const out = {
+    name: name.slice(0, 100),
+    address1: address1.slice(0, 200),
+    city: city.slice(0, 100),
+    country: country.slice(0, 100),
+    zip: zip.slice(0, 20),
+  };
+  if (s.address2) out.address2 = String(s.address2).trim().slice(0, 200);
+  if (s.province) out.province = String(s.province).trim().slice(0, 100);
+  if (s.phone)    out.phone    = String(s.phone).trim().slice(0, 30);
+  return out;
+}
+
+// HMAC verification per Shopify docs: base64(HMAC_SHA256(rawBody, secret)) === x-shopify-hmac-sha256.
+// Returns true / false when secret + header are present, null when not configured (dev unsigned mode).
+function verifyShopifyHmac(rawBody, hmacHeader, secret) {
+  if (!secret) return null;
+  if (!hmacHeader || !rawBody) return false;
+  const buf = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody), 'utf8');
+  const digest = crypto.createHmac('sha256', secret).update(buf).digest('base64');
+  const a = Buffer.from(digest);
+  const b = Buffer.from(String(hmacHeader));
+  if (a.length !== b.length) return false;
+  try { return crypto.timingSafeEqual(a, b); } catch (_) { return false; }
+}
+
+function sumDiscounts(li) {
+  const arr = li.discount_allocations || [];
+  return arr.reduce((s, d) => s + Number(d.amount || 0), 0);
+}
+function sumTax(li) {
+  const arr = li.tax_lines || [];
+  return arr.reduce((s, t) => s + Number(t.price || 0), 0);
+}
+
+// Parse a Shopify order object → array of marketplace-attributed line items.
+// Pulls pattern_id / designer_id / mp_product_type from line_item.properties first,
+// then falls back to order.note_attributes for orders that only stamped attribution
+// at the order level. Returns [] if no mp attribution found.
+function extractMpLineItems(order) {
+  if (!order || !Array.isArray(order.line_items)) return [];
+  const noteAttrs = {};
+  for (const a of (order.note_attributes || [])) {
+    if (a && a.name) noteAttrs[a.name] = a.value;
+  }
+  const out = [];
+  for (const li of order.line_items) {
+    const props = {};
+    for (const p of (li.properties || [])) {
+      if (p && p.name) props[p.name] = p.value;
+    }
+    const patternId = Number(props.pattern_id || noteAttrs.mp_pattern_id || 0);
+    if (!patternId) continue;
+    const productType = (props.mp_product_type || noteAttrs.mp_order_type || 'sample');
+    const quantity = Number(li.quantity || 1);
+    const price = Number(li.price || 0);
+    out.push({
+      patternId,
+      patternSlug: props.pattern_slug || noteAttrs.mp_pattern_slug || null,
+      designerId: Number(props.designer_id || noteAttrs.mp_designer_id || 0) || null,
+      productType,
+      title: li.title || '',
+      quantity,
+      unitPrice: price,
+      lineTotal: Number((price * quantity).toFixed(2)),
+      discount: sumDiscounts(li),
+      tax: sumTax(li),
+      shopifyLineItemId: li.id,
+    });
+  }
+  return out;
+}
+
+function entryTypeFor(productType) {
+  if (productType === 'sample') return 'sample_credit';
+  if (productType === 'license') return 'license_sale';
+  if (productType === 'recolor') return 'custom_recolor';
+  return 'wallpaper_sale';
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Route handlers
+// ─────────────────────────────────────────────────────────────────────────────
+
+async function createSampleOrder(req, res) {
+  try {
+    const b = req.body || {};
+    const slug = safeText(b.pattern_slug, 100);
+    const email = safeText(b.buyer_email, 200);
+    if (!slug)         return res.status(400).json({ error: 'pattern_slug required' });
+    if (!emailOk(email)) return res.status(400).json({ error: 'valid buyer_email required' });
+    const ship = shipToValid(b.ship_to);
+    if (!ship) {
+      return res.status(400).json({
+        error: 'ship_to must include name, address1, city, country, zip (province + address2 + phone optional)',
+      });
+    }
+
+    const pattern = await one(
+      `SELECT p.id, p.title, p.slug, p.designer_id, p.sample_price,
+              d.display_name AS designer_name, d.slug AS designer_slug
+         FROM mp_patterns p
+         JOIN mp_designer_profiles d ON d.id = p.designer_id
+        WHERE p.slug=$1 AND p.status='approved'`,
+      [slug]
+    );
+    if (!pattern) return res.status(404).json({ error: 'pattern not found or not approved' });
+
+    const samplePrice = Number(pattern.sample_price || SAMPLE_PRICE_DEFAULT).toFixed(2);
+
+    if (!shopifyConfigured()) {
+      return res.status(503).json({
+        error: 'Shopify not configured — set SHOPIFY_STORE + SHOPIFY_ADMIN_TOKEN in .env',
+      });
+    }
+
+    const draft = await createDraftOrder({
+      email: email.toLowerCase(),
+      line_items: [
+        {
+          title: `Sample — ${pattern.title}`,
+          price: samplePrice,
+          quantity: 1,
+          requires_shipping: true,
+          taxable: false,
+          properties: [
+            { name: 'pattern_slug', value: pattern.slug },
+            { name: 'pattern_id', value: String(pattern.id) },
+            { name: 'designer_slug', value: pattern.designer_slug },
+            { name: 'designer_id', value: String(pattern.designer_id) },
+            { name: 'mp_product_type', value: 'sample' },
+          ],
+        },
+      ],
+      shipping_address: ship,
+      note: `Wallco marketplace sample — ${pattern.title} by ${pattern.designer_name}`,
+      note_attributes: [
+        { name: 'mp_order_type', value: 'sample' },
+        { name: 'mp_pattern_slug', value: pattern.slug },
+        { name: 'mp_pattern_id', value: String(pattern.id) },
+        { name: 'mp_designer_id', value: String(pattern.designer_id) },
+      ],
+      tags: `mp-sample, mp-pattern-${pattern.slug}`,
+      use_customer_default_address: false,
+    });
+
+    // Optimistic sample_count bump. Real wallpaper sale count rises when the
+    // orders/fulfilled webhook lands on a non-sample line.
+    await q(`UPDATE mp_patterns SET sample_count = sample_count + 1 WHERE id=$1`, [pattern.id]).catch(() => {});
+
+    res.json({
+      ok: true,
+      draft_order: {
+        id: draft.draft_order.id,
+        invoice_url: draft.draft_order.invoice_url,
+        total_price: draft.draft_order.total_price,
+        currency: draft.draft_order.currency,
+      },
+      pattern: {
+        slug: pattern.slug,
+        title: pattern.title,
+        designer_name: pattern.designer_name,
+        designer_slug: pattern.designer_slug,
+      },
+    });
+  } catch (err) {
+    console.error('[orders/sample] ', err);
+    res.status(err.status === 422 ? 422 : 500).json({ error: err.message });
+  }
+}
+
+// Ingest a Shopify fulfilled-order payload into mp_orders + mp_order_items +
+// mp_commission_ledger. Idempotent on external_shopify_order_id.
+// Exported separately so it's callable from the webhook handler and from tests
+// (with a mocked `deps` object).
+async function ingestFulfilledOrder(order, deps = {}) {
+  const q_ = deps.q || q;
+  const one_ = deps.one || one;
+  if (!order || !order.id) throw new Error('order.id required');
+
+  const items = extractMpLineItems(order);
+  if (!items.length) return { skipped: true, reason: 'no marketplace line items' };
+
+  // Resolve any missing designer_id from the pattern.
+  for (const li of items) {
+    if (!li.designerId) {
+      const p = await one_(`SELECT designer_id FROM mp_patterns WHERE id=$1`, [li.patternId]);
+      li.designerId = p && p.designer_id ? p.designer_id : null;
+    }
+  }
+
+  // Idempotency: skip if we've already ingested this Shopify order.
+  const existing = await one_(`SELECT id FROM mp_orders WHERE external_shopify_order_id=$1`, [String(order.id)]);
+  if (existing) return { skipped: true, reason: 'already ingested', orderId: existing.id };
+
+  // Upsert buyer user.
+  const email = (order.email || order.contact_email || '').toLowerCase().trim();
+  let userId = null;
+  if (email && emailOk(email)) {
+    const fullName = order.customer && (order.customer.first_name || order.customer.last_name)
+      ? `${order.customer.first_name || ''} ${order.customer.last_name || ''}`.trim()
+      : null;
+    const u = await one_(
+      `INSERT INTO mp_users (email, display_name, role)
+       VALUES ($1, $2, 'buyer')
+       ON CONFLICT (email) DO UPDATE SET updated_at = now(), role = COALESCE(mp_users.role, 'buyer')
+       RETURNING id`,
+      [email, fullName || email]
+    );
+    userId = u.id;
+  }
+
+  const subtotal = items.reduce((s, li) => s + li.lineTotal, 0);
+  const total = Number(order.total_price != null ? order.total_price : subtotal);
+  const allSample = items.every((li) => li.productType === 'sample');
+  const orderType = allSample ? 'sample' : (items[0].productType || 'wallpaper');
+  const fulfilledAt = order.fulfilled_at || order.closed_at || order.updated_at || new Date().toISOString();
+
+  const created = await one_(
+    `INSERT INTO mp_orders
+       (user_id, external_shopify_order_id, order_type, subtotal, total, status, fulfilled_at)
+     VALUES ($1,$2,$3,$4,$5,'fulfilled',$6)
+     RETURNING id`,
+    [userId, String(order.id), orderType, subtotal.toFixed(2), total.toFixed(2), fulfilledAt]
+  );
+  const orderId = created.id;
+
+  const itemRows = [];
+  const ledgerRows = [];
+
+  for (const li of items) {
+    if (!li.designerId) {
+      console.warn('[mp/ingest] skipping line item — no designer_id resolved', li);
+      continue;
+    }
+
+    const designer = await one_(
+      `SELECT commission_rate, is_founding FROM mp_designer_profiles WHERE id=$1`,
+      [li.designerId]
+    );
+    const fallbackRate = designer && designer.commission_rate != null
+      ? Number(designer.commission_rate)
+      : commissions.getDefaultCommissionRate({ isFounding: !!(designer && designer.is_founding) });
+
+    const orderItemRow = {
+      line_total: li.lineTotal,
+      unit_price: li.unitPrice,
+      quantity: li.quantity,
+      discount_amount: li.discount || 0,
+      refund_amount: 0,
+      sample_credit_amount: 0,
+      commission_rate: fallbackRate,
+    };
+    const { basis, rate, amount } = commissions.buildLedgerEntry(orderItemRow, { fallbackRate });
+
+    const item = await one_(
+      `INSERT INTO mp_order_items
+         (order_id, pattern_id, designer_id, product_type, quantity, unit_price, line_total,
+          discount_amount, tax_amount, commission_rate, commission_amount, commission_basis)
+       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
+       RETURNING id`,
+      [orderId, li.patternId, li.designerId, li.productType,
+       li.quantity, li.unitPrice.toFixed(2), li.lineTotal.toFixed(2),
+       (li.discount || 0).toFixed(2), (li.tax || 0).toFixed(2),
+       rate.toFixed(2), amount.toFixed(2), basis.toFixed(2)]
+    );
+    itemRows.push(item);
+
+    const ledger = await one_(
+      `INSERT INTO mp_commission_ledger
+         (designer_id, order_id, order_item_id, pattern_id, entry_type,
+          basis_amount, commission_rate, commission_amount, status, notes)
+       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'pending',$9)
+       RETURNING id`,
+      [li.designerId, orderId, item.id, li.patternId, entryTypeFor(li.productType),
+       basis.toFixed(2), rate.toFixed(2), amount.toFixed(2),
+       `Shopify order ${order.id} line ${li.shopifyLineItemId}`]
+    );
+    ledgerRows.push(ledger);
+
+    if (li.productType !== 'sample') {
+      await q_(`UPDATE mp_patterns SET sale_count = sale_count + 1 WHERE id=$1`, [li.patternId]).catch(() => {});
+    }
+  }
+
+  return {
+    ok: true,
+    orderId,
+    items: itemRows.length,
+    ledger: ledgerRows.length,
+    skipped: false,
+  };
+}
+
+function makeWebhookHandler({ webhookSecret } = {}) {
+  const secret = webhookSecret != null ? webhookSecret : (process.env.SHOPIFY_WEBHOOK_SECRET || '');
+  return async function shopifyOrdersFulfilledWebhook(req, res) {
+    try {
+      const raw = req.body; // Buffer because express.raw() is applied to this route
+      if (!raw || (!Buffer.isBuffer(raw) && typeof raw !== 'string')) {
+        return res.status(400).json({ error: 'raw body required (mount with express.raw)' });
+      }
+      const hmac = req.headers['x-shopify-hmac-sha256'];
+      const verified = verifyShopifyHmac(raw, hmac, secret);
+      if (verified === false) {
+        console.warn('[mp/webhook orders-fulfilled] HMAC verification FAILED');
+        return res.status(401).json({ error: 'invalid hmac' });
+      }
+      if (verified === null) {
+        console.warn('[mp/webhook orders-fulfilled] no SHOPIFY_WEBHOOK_SECRET — accepting unsigned (dev)');
+      }
+      const text = Buffer.isBuffer(raw) ? raw.toString('utf8') : raw;
+      const order = JSON.parse(text);
+      const out = await ingestFulfilledOrder(order);
+      res.json(out);
+    } catch (err) {
+      console.error('[mp/webhook orders-fulfilled] ', err);
+      res.status(500).json({ error: err.message });
+    }
+  };
+}
+
+module.exports = {
+  // route handlers
+  createSampleOrder,
+  makeWebhookHandler,
+  // ingestion
+  ingestFulfilledOrder,
+  // pure helpers (also exported for tests)
+  shipToValid,
+  verifyShopifyHmac,
+  extractMpLineItems,
+  entryTypeFor,
+  sumDiscounts,
+  sumTax,
+  SAMPLE_PRICE_DEFAULT,
+};
diff --git a/src/marketplace/seo.js b/src/marketplace/seo.js
new file mode 100644
index 0000000..06207e5
--- /dev/null
+++ b/src/marketplace/seo.js
@@ -0,0 +1,269 @@
+// Marketplace SEO module: sitemap + per-page meta + JSON-LD injection.
+//
+// All functions in this file are pure (no I/O, no DB). The mount() function in
+// index.js handles DB reads and wires these into Express routes.
+//
+// Why server-render instead of client-side: crawlers (Googlebot, Bingbot,
+// social card unfurlers) do execute JS but won't wait for fetch() chains and
+// can't see Open Graph / JSON-LD that arrives after first paint. Search rank
+// for designer/pattern pages depends on meta+JSON-LD being in the initial
+// response body.
+
+const SITE = (process.env.MP_PUBLIC_BASE || 'https://wallco.ai').replace(/\/$/, '');
+
+// ── Escapers
+function escHtml(s) {
+  return String(s == null ? '' : s)
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+    .replace(/'/g, '&#39;');
+}
+function escAttr(s) { return escHtml(s); }
+function escXml(s) {
+  return String(s == null ? '' : s)
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+    .replace(/'/g, '&apos;');
+}
+// JSON-LD: <script type="application/ld+json"> means JSON gets HTML-parsed first;
+// escape </ to avoid an embedded "</script>" closing the tag prematurely.
+function jsonLdSafe(obj) {
+  return JSON.stringify(obj).replace(/<\//g, '<\\/');
+}
+
+function absUrl(u) {
+  if (!u) return '';
+  if (/^https?:\/\//i.test(u)) return u;
+  if (u.startsWith('/')) return SITE + u;
+  return SITE + '/' + u;
+}
+
+function truncate(s, n) {
+  s = String(s || '').replace(/\s+/g, ' ').trim();
+  if (s.length <= n) return s;
+  return s.slice(0, n - 1).trimEnd() + '…';
+}
+
+// ── Sitemap generation
+// Emits the XML for one <urlset> with every approved designer URL + every
+// approved pattern URL. Date is per-row updated_at (falls back to created_at).
+function buildSitemapXml(designers, patterns) {
+  const today = new Date().toISOString().slice(0, 10);
+  function lastmod(row) {
+    const t = row && (row.updated_at || row.created_at);
+    if (!t) return today;
+    try { return new Date(t).toISOString().slice(0, 10); } catch (_) { return today; }
+  }
+  const urls = [];
+  // Stable section markers (browsing pages)
+  urls.push(`  <url><loc>${SITE}/marketplace</loc><changefreq>daily</changefreq><priority>0.9</priority><lastmod>${today}</lastmod></url>`);
+  urls.push(`  <url><loc>${SITE}/designers</loc><changefreq>daily</changefreq><priority>0.8</priority><lastmod>${today}</lastmod></url>`);
+  urls.push(`  <url><loc>${SITE}/patterns</loc><changefreq>daily</changefreq><priority>0.8</priority><lastmod>${today}</lastmod></url>`);
+
+  for (const d of designers || []) {
+    if (!d || !d.slug) continue;
+    urls.push(
+      `  <url><loc>${SITE}/designers/${escXml(d.slug)}</loc>` +
+      `<changefreq>weekly</changefreq><priority>0.7</priority>` +
+      `<lastmod>${lastmod(d)}</lastmod></url>`
+    );
+    urls.push(
+      `  <url><loc>${SITE}/store/${escXml(d.slug)}</loc>` +
+      `<changefreq>weekly</changefreq><priority>0.6</priority>` +
+      `<lastmod>${lastmod(d)}</lastmod></url>`
+    );
+  }
+  for (const p of patterns || []) {
+    if (!p || !p.slug) continue;
+    urls.push(
+      `  <url><loc>${SITE}/patterns/${escXml(p.slug)}</loc>` +
+      `<changefreq>weekly</changefreq><priority>${p.featured ? '0.8' : '0.6'}</priority>` +
+      `<lastmod>${lastmod(p)}</lastmod></url>`
+    );
+  }
+  return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join('\n')}\n</urlset>\n`;
+}
+
+// ── Designer meta + JSON-LD Person
+function designerJsonLd(d) {
+  const url = `${SITE}/designers/${d.slug}`;
+  const sameAs = [];
+  if (d.website_url)    sameAs.push(String(d.website_url));
+  if (d.instagram_url)  sameAs.push(String(d.instagram_url));
+  const obj = {
+    '@context': 'https://schema.org',
+    '@type': 'Person',
+    name: d.display_name,
+    url,
+    description: truncate(d.bio || `${d.display_name} on Wallco — designer-warranted wallcovering patterns.`, 300),
+  };
+  if (d.avatar_url) obj.image = absUrl(d.avatar_url);
+  if (d.studio_name) obj.affiliation = { '@type': 'Organization', name: d.studio_name };
+  if (d.location) obj.address = d.location;
+  if (sameAs.length) obj.sameAs = sameAs;
+  if (d.style_tags && d.style_tags.length) obj.knowsAbout = d.style_tags;
+  return obj;
+}
+
+function designerMetaBlock(d) {
+  const title    = `${d.display_name}${d.studio_name ? ' · ' + d.studio_name : ''} — Wallco Designer`;
+  const descRaw  = d.bio || `${d.display_name} on Wallco — designer-warranted wallcovering patterns.`;
+  const desc     = truncate(descRaw, 200);
+  const url      = `${SITE}/designers/${d.slug}`;
+  const image    = absUrl(d.cover_url || d.avatar_url || '/marketplace/og-default.png');
+  const robots   = (d.status === 'approved') ? 'index,follow' : 'noindex,nofollow';
+  const jsonLd   = designerJsonLd(d);
+
+  return [
+    `<title>${escHtml(title)}</title>`,
+    `<meta name="description" content="${escAttr(desc)}" />`,
+    `<meta name="robots" content="${escAttr(robots)}" />`,
+    `<link rel="canonical" href="${escAttr(url)}" />`,
+    `<meta property="og:type" content="profile" />`,
+    `<meta property="og:title" content="${escAttr(title)}" />`,
+    `<meta property="og:description" content="${escAttr(desc)}" />`,
+    `<meta property="og:url" content="${escAttr(url)}" />`,
+    `<meta property="og:image" content="${escAttr(image)}" />`,
+    `<meta property="og:site_name" content="Wallco" />`,
+    `<meta name="twitter:card" content="summary_large_image" />`,
+    `<meta name="twitter:title" content="${escAttr(title)}" />`,
+    `<meta name="twitter:description" content="${escAttr(desc)}" />`,
+    `<meta name="twitter:image" content="${escAttr(image)}" />`,
+    `<script type="application/ld+json">${jsonLdSafe(jsonLd)}</script>`,
+  ].join('\n');
+}
+
+// ── Pattern meta + JSON-LD Product + CreativeWork
+function patternJsonLd(p, d) {
+  const url = `${SITE}/patterns/${p.slug}`;
+  const image = absUrl(p.original_image_url || p.thumbnail_url || '');
+  const creator = {
+    '@type': 'Person',
+    name: d ? d.display_name : 'Wallco Designer',
+    url: d ? `${SITE}/designers/${d.slug}` : SITE,
+  };
+  const description = truncate(p.description || `${p.title} — designer-warranted wallcovering pattern on Wallco.`, 300);
+
+  const product = {
+    '@context': 'https://schema.org',
+    '@type': 'Product',
+    name: p.title,
+    sku: 'mp-' + (p.slug || p.id),
+    url,
+    image,
+    description,
+    brand: { '@type': 'Brand', name: 'Wallco' },
+    category: 'Wallcovering / Wallpaper',
+  };
+  if (d) product.manufacturer = creator;
+  // Schema.org allows offers as a single object; we use AggregateOffer when a
+  // sample price + base price both exist so crawlers index both tiers.
+  const offers = [];
+  if (p.sample_price != null) {
+    offers.push({
+      '@type': 'Offer', name: 'Sample', priceCurrency: 'USD',
+      price: Number(p.sample_price).toFixed(2),
+      availability: 'https://schema.org/InStock', url,
+    });
+  }
+  if (p.base_price != null) {
+    offers.push({
+      '@type': 'Offer', name: 'Roll', priceCurrency: 'USD',
+      price: Number(p.base_price).toFixed(2),
+      availability: 'https://schema.org/InStock', url,
+    });
+  }
+  if (offers.length === 1) product.offers = offers[0];
+  else if (offers.length > 1) {
+    const prices = offers.map(o => Number(o.price));
+    product.offers = {
+      '@type': 'AggregateOffer',
+      priceCurrency: 'USD',
+      lowPrice: Math.min(...prices).toFixed(2),
+      highPrice: Math.max(...prices).toFixed(2),
+      offerCount: offers.length,
+      offers,
+    };
+  }
+  const creativeWork = {
+    '@context': 'https://schema.org',
+    '@type': 'CreativeWork',
+    name: p.title,
+    url,
+    image,
+    description,
+    creator,
+    inLanguage: 'en',
+    license: `${SITE}/licensing/${p.slug}`,
+    keywords: [].concat(p.style_tags || [], p.motif_tags || [], p.color_tags || []).filter(Boolean).join(', ') || undefined,
+  };
+  // Returning an array of two JSON-LD blocks is valid per the spec (each in
+  // its own <script>) — Google Rich Results consumes both. Caller wraps them.
+  return [product, creativeWork];
+}
+
+function patternMetaBlock(p, d) {
+  const designerName = d ? d.display_name : 'Wallco Designer';
+  const title  = `${p.title} by ${designerName} — Wallco`;
+  const desc   = truncate(p.description || `${p.title} — designer-warranted wallcovering pattern on Wallco by ${designerName}.`, 200);
+  const url    = `${SITE}/patterns/${p.slug}`;
+  const image  = absUrl(p.original_image_url || p.thumbnail_url || '/marketplace/og-default.png');
+  const robots = (p.status === 'approved') ? 'index,follow' : 'noindex,nofollow';
+  const blocks = patternJsonLd(p, d);
+
+  const tags = [].concat(p.style_tags || [], p.motif_tags || [], p.color_tags || [], p.room_tags || []).filter(Boolean);
+  const keywords = tags.length ? `<meta name="keywords" content="${escAttr(tags.join(', '))}" />` : '';
+
+  return [
+    `<title>${escHtml(title)}</title>`,
+    `<meta name="description" content="${escAttr(desc)}" />`,
+    `<meta name="robots" content="${escAttr(robots)}" />`,
+    keywords,
+    `<link rel="canonical" href="${escAttr(url)}" />`,
+    `<meta property="og:type" content="product" />`,
+    `<meta property="og:title" content="${escAttr(title)}" />`,
+    `<meta property="og:description" content="${escAttr(desc)}" />`,
+    `<meta property="og:url" content="${escAttr(url)}" />`,
+    `<meta property="og:image" content="${escAttr(image)}" />`,
+    `<meta property="og:site_name" content="Wallco" />`,
+    `<meta name="twitter:card" content="summary_large_image" />`,
+    `<meta name="twitter:title" content="${escAttr(title)}" />`,
+    `<meta name="twitter:description" content="${escAttr(desc)}" />`,
+    `<meta name="twitter:image" content="${escAttr(image)}" />`,
+    ...blocks.map(b => `<script type="application/ld+json">${jsonLdSafe(b)}</script>`),
+  ].filter(Boolean).join('\n');
+}
+
+// ── HTML injection
+// Replaces the existing static <title>…</title> (if any) and injects the new
+// meta block just before </head>. If </head> is missing, prepends to <body>.
+function injectIntoHead(html, metaBlock) {
+  if (typeof html !== 'string') return html;
+  // Drop the static title — we replace it with our SEO title.
+  let out = html.replace(/<title>[\s\S]*?<\/title>/i, '');
+  // Inject before </head>.
+  if (/<\/head>/i.test(out)) {
+    out = out.replace(/<\/head>/i, metaBlock + '\n</head>');
+  } else if (/<body[\s>]/i.test(out)) {
+    out = out.replace(/(<body[^>]*>)/i, metaBlock + '\n$1');
+  } else {
+    out = metaBlock + '\n' + out;
+  }
+  return out;
+}
+
+module.exports = {
+  SITE,
+  buildSitemapXml,
+  designerJsonLd,
+  designerMetaBlock,
+  patternJsonLd,
+  patternMetaBlock,
+  injectIntoHead,
+  // Re-exported for tests
+  _internal: { escHtml, escAttr, escXml, jsonLdSafe, absUrl, truncate },
+};
diff --git a/src/marketplace/shopify.js b/src/marketplace/shopify.js
new file mode 100644
index 0000000..0ddce0f
--- /dev/null
+++ b/src/marketplace/shopify.js
@@ -0,0 +1,56 @@
+// Thin wrapper around the Shopify Admin REST API for the marketplace layer.
+// Reads creds from the wallco-ai .env (SHOPIFY_STORE + SHOPIFY_ADMIN_TOKEN) —
+// same creds DW uses elsewhere; the store points at the DW Shopify Admin.
+
+const STORE = process.env.SHOPIFY_STORE || process.env.SHOPIFY_STORE_DOMAIN || '';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || '';
+const API_VERSION = process.env.SHOPIFY_API_VERSION || '2024-10';
+
+function isConfigured() {
+  return !!(STORE && TOKEN);
+}
+
+async function adminFetch(pathname, opts = {}) {
+  if (!isConfigured()) {
+    throw new Error('Shopify not configured (SHOPIFY_STORE + SHOPIFY_ADMIN_TOKEN required)');
+  }
+  const url = `https://${STORE}/admin/api/${API_VERSION}${pathname}`;
+  const headers = {
+    'X-Shopify-Access-Token': TOKEN,
+    'Content-Type': 'application/json',
+    'Accept': 'application/json',
+    ...(opts.headers || {}),
+  };
+  const res = await fetch(url, { ...opts, headers });
+  const text = await res.text();
+  let json = null;
+  try { json = text ? JSON.parse(text) : null; } catch (_) { /* non-JSON body */ }
+  if (!res.ok) {
+    const msg = json && json.errors ? JSON.stringify(json.errors) : text.slice(0, 400);
+    const err = new Error(`shopify ${res.status} ${pathname}: ${msg}`);
+    err.status = res.status;
+    err.body = json || text;
+    throw err;
+  }
+  return json;
+}
+
+async function createDraftOrder(payload) {
+  return adminFetch('/draft_orders.json', {
+    method: 'POST',
+    body: JSON.stringify({ draft_order: payload }),
+  });
+}
+
+async function getOrder(orderId) {
+  return adminFetch(`/orders/${orderId}.json`);
+}
+
+module.exports = {
+  isConfigured,
+  adminFetch,
+  createDraftOrder,
+  getOrder,
+  STORE,
+  API_VERSION,
+};
diff --git a/tests/marketplace/search.test.js b/tests/marketplace/search.test.js
new file mode 100644
index 0000000..2114382
--- /dev/null
+++ b/tests/marketplace/search.test.js
@@ -0,0 +1,134 @@
+// Postgres FTS endpoint tests for /api/marketplace/search.
+// Run with: node tests/marketplace/search.test.js
+// Assumes wallco-ai is up on :9792 with sql/100 + sql/101 already applied
+// and at least the seed designer/pattern fixtures (astrid-mauve etc.) loaded.
+const assert = require('assert');
+const http = require('http');
+
+const HOST = process.env.WALLCO_HOST || '127.0.0.1';
+const PORT = process.env.WALLCO_PORT || 9792;
+
+function req(method, path) {
+  return new Promise((resolve, reject) => {
+    const opts = { hostname: HOST, port: PORT, path, method, headers: { 'content-type': 'application/json' } };
+    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) }));
+    });
+    r.on('error', reject);
+    r.end();
+  });
+}
+function tryJson(s){ try { return JSON.parse(s); } catch(_) { return null; } }
+
+const tests = [];
+function t(name, fn) { tests.push({ name, fn }); }
+
+t('GET /api/marketplace/search with no params returns array (lists all approved)', async () => {
+  const r = await req('GET', '/api/marketplace/search');
+  assert.strictEqual(r.status, 200);
+  assert.ok(Array.isArray(r.json.patterns), 'patterns is array');
+  assert.ok(r.json.query, 'query echo present');
+  assert.deepStrictEqual(r.json.query.style, []);
+  assert.strictEqual(r.json.query.commercial, null);
+});
+
+t('GET /api/marketplace/search?q=floral returns patterns shaped correctly + rank field', async () => {
+  const r = await req('GET', '/api/marketplace/search?q=floral');
+  assert.strictEqual(r.status, 200);
+  assert.ok(Array.isArray(r.json.patterns));
+  assert.strictEqual(r.json.query.q, 'floral');
+  if (r.json.patterns.length) {
+    const p = r.json.patterns[0];
+    assert.ok('id' in p && 'title' in p && 'slug' in p, 'pattern has id/title/slug');
+    assert.ok('designer_name' in p, 'pattern joins designer');
+    assert.ok('rank' in p, 'rank present when q given');
+    assert.ok(typeof p.rank === 'number' || typeof p.rank === 'string', 'rank is numeric');
+  }
+});
+
+t('GET /api/marketplace/search?q=<gibberish> returns 0 patterns', async () => {
+  const r = await req('GET', '/api/marketplace/search?q=zzzqqqxxxnotapatternname12345');
+  assert.strictEqual(r.status, 200);
+  assert.strictEqual(r.json.patterns.length, 0);
+  assert.strictEqual(r.json.total_returned, 0);
+});
+
+t('GET /api/marketplace/search?style=floral filters tag arrays', async () => {
+  const r = await req('GET', '/api/marketplace/search?style=floral');
+  assert.strictEqual(r.status, 200);
+  assert.deepStrictEqual(r.json.query.style, ['floral']);
+  // Every returned pattern must have 'floral' in style_tags (case-sensitive on the array)
+  for (const p of r.json.patterns) {
+    assert.ok(Array.isArray(p.style_tags), 'style_tags array');
+    assert.ok(p.style_tags.includes('floral'), `pattern ${p.slug} should have 'floral' in style_tags but had ${JSON.stringify(p.style_tags)}`);
+  }
+});
+
+t('GET /api/marketplace/search?style=a,b parses comma list', async () => {
+  const r = await req('GET', '/api/marketplace/search?style=floral,modern');
+  assert.strictEqual(r.status, 200);
+  assert.deepStrictEqual(r.json.query.style.sort(), ['floral','modern']);
+});
+
+t('GET /api/marketplace/search?commercial=true filters bool', async () => {
+  const r = await req('GET', '/api/marketplace/search?commercial=true');
+  assert.strictEqual(r.status, 200);
+  assert.strictEqual(r.json.query.commercial, true);
+  for (const p of r.json.patterns) {
+    assert.strictEqual(p.commercial_suitable, true, `pattern ${p.slug} commercial_suitable should be true`);
+  }
+});
+
+t('GET /api/marketplace/search?commercial=false filters bool', async () => {
+  const r = await req('GET', '/api/marketplace/search?commercial=false');
+  assert.strictEqual(r.status, 200);
+  assert.strictEqual(r.json.query.commercial, false);
+  for (const p of r.json.patterns) {
+    assert.strictEqual(p.commercial_suitable, false, `pattern ${p.slug} commercial_suitable should be false`);
+  }
+});
+
+t('GET /api/marketplace/search ignores invalid commercial value', async () => {
+  const r = await req('GET', '/api/marketplace/search?commercial=maybe');
+  assert.strictEqual(r.status, 200);
+  assert.strictEqual(r.json.query.commercial, null);
+});
+
+t('GET /api/marketplace/search?q=floral&style=floral combines filters (AND semantics)', async () => {
+  const r = await req('GET', '/api/marketplace/search?q=floral&style=floral');
+  assert.strictEqual(r.status, 200);
+  // All returned patterns must satisfy both filters; tsv match implies floral keyword
+  for (const p of r.json.patterns) {
+    assert.ok(p.style_tags.includes('floral'));
+  }
+});
+
+t('GET /api/marketplace/search respects limit', async () => {
+  const r = await req('GET', '/api/marketplace/search?limit=2');
+  assert.strictEqual(r.status, 200);
+  assert.strictEqual(r.json.limit, 2);
+  assert.ok(r.json.patterns.length <= 2);
+});
+
+t('GET /api/marketplace/search caps limit at 200', async () => {
+  const r = await req('GET', '/api/marketplace/search?limit=10000');
+  assert.strictEqual(r.status, 200);
+  assert.strictEqual(r.json.limit, 200);
+});
+
+t('GET /api/marketplace/search rejects no SQL injection (q with quote)', async () => {
+  const r = await req('GET', "/api/marketplace/search?q=" + encodeURIComponent("'; DROP TABLE mp_patterns; --"));
+  assert.strictEqual(r.status, 200, 'parameterized query handles quote safely');
+  assert.ok(Array.isArray(r.json.patterns));
+});
+
+(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} search tests passed`);
+  if (fail) process.exit(1);
+})();
diff --git a/tests/unit/marketplace-seo.test.js b/tests/unit/marketplace-seo.test.js
new file mode 100644
index 0000000..371585b
--- /dev/null
+++ b/tests/unit/marketplace-seo.test.js
@@ -0,0 +1,262 @@
+'use strict';
+// Tests for src/marketplace/seo.js — pure functions, no DB, no HTTP.
+// Run with: node --test tests/unit/marketplace-seo.test.js
+
+const { test, describe } = require('node:test');
+const assert = require('node:assert/strict');
+
+const seo = require('../../src/marketplace/seo.js');
+const { buildSitemapXml, designerMetaBlock, designerJsonLd,
+        patternMetaBlock, patternJsonLd, injectIntoHead } = seo;
+
+// Pull the first JSON-LD payload out of a meta block we just rendered.
+function firstJsonLd(metaBlock) {
+  const m = metaBlock.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/);
+  return m ? JSON.parse(m[1].replace(/<\\\//g, '</')) : null;
+}
+function allJsonLd(metaBlock) {
+  const re = /<script type="application\/ld\+json">([\s\S]*?)<\/script>/g;
+  const out = [];
+  let m;
+  while ((m = re.exec(metaBlock))) {
+    out.push(JSON.parse(m[1].replace(/<\\\//g, '</')));
+  }
+  return out;
+}
+
+// ---------------------------------------------------------------------------
+// buildSitemapXml
+// ---------------------------------------------------------------------------
+describe('buildSitemapXml', () => {
+  test('emits valid XML header and urlset wrapper', () => {
+    const xml = buildSitemapXml([], []);
+    assert.match(xml, /^<\?xml version="1\.0" encoding="UTF-8"\?>/);
+    assert.match(xml, /<urlset xmlns="http:\/\/www\.sitemaps\.org\/schemas\/sitemap\/0\.9">/);
+    assert.match(xml, /<\/urlset>/);
+  });
+
+  test('always emits the three section URLs even with zero rows', () => {
+    const xml = buildSitemapXml([], []);
+    assert.match(xml, /<loc>https:\/\/wallco\.ai\/marketplace<\/loc>/);
+    assert.match(xml, /<loc>https:\/\/wallco\.ai\/designers<\/loc>/);
+    assert.match(xml, /<loc>https:\/\/wallco\.ai\/patterns<\/loc>/);
+  });
+
+  test('emits one designer URL and one store URL per row', () => {
+    const xml = buildSitemapXml(
+      [{ slug: 'astrid-mauve', updated_at: '2026-01-01T00:00:00Z' }],
+      []
+    );
+    assert.match(xml, /<loc>https:\/\/wallco\.ai\/designers\/astrid-mauve<\/loc>/);
+    assert.match(xml, /<loc>https:\/\/wallco\.ai\/store\/astrid-mauve<\/loc>/);
+    assert.match(xml, /<lastmod>2026-01-01<\/lastmod>/);
+  });
+
+  test('emits pattern URLs with priority 0.8 when featured, 0.6 otherwise', () => {
+    const xml = buildSitemapXml(
+      [],
+      [
+        { slug: 'wildflower-reverie', featured: true,  updated_at: '2026-01-02' },
+        { slug: 'plain-pattern',      featured: false, updated_at: '2026-01-02' },
+      ]
+    );
+    const featuredLine = xml.split('\n').find(l => l.includes('wildflower-reverie'));
+    const plainLine    = xml.split('\n').find(l => l.includes('plain-pattern'));
+    assert.match(featuredLine, /<priority>0\.8<\/priority>/);
+    assert.match(plainLine,    /<priority>0\.6<\/priority>/);
+  });
+
+  test('escapes XML-special characters in slugs', () => {
+    const xml = buildSitemapXml([{ slug: 'a&b<c>"d', created_at: '2026-01-01' }], []);
+    assert.ok(!xml.includes('a&b<c>"d'));
+    assert.match(xml, /a&amp;b&lt;c&gt;&quot;d/);
+  });
+
+  test('skips rows without a slug', () => {
+    const xml = buildSitemapXml([{ slug: null }, { slug: '' }, undefined], []);
+    // Only the 3 static section URLs.
+    const locCount = (xml.match(/<loc>/g) || []).length;
+    assert.equal(locCount, 3);
+  });
+
+  test('falls back to today when no created_at/updated_at present', () => {
+    const xml = buildSitemapXml([{ slug: 'no-dates' }], []);
+    const today = new Date().toISOString().slice(0, 10);
+    assert.ok(xml.includes(`<lastmod>${today}</lastmod>`));
+  });
+});
+
+// ---------------------------------------------------------------------------
+// designerJsonLd
+// ---------------------------------------------------------------------------
+describe('designerJsonLd', () => {
+  test('renders required schema.org/Person fields', () => {
+    const ld = designerJsonLd({
+      display_name: 'Astrid Mauve', slug: 'astrid-mauve',
+      bio: 'Botanical illustrator from Paris.', avatar_url: '/marketplace/uploads/avatar.jpg',
+      studio_name: 'Mauve Studio', location: 'Paris, FR',
+      website_url: 'https://astridmauve.example', instagram_url: 'https://instagram.com/astridmauve',
+      style_tags: ['botanical', 'maximalist'],
+    });
+    assert.equal(ld['@context'], 'https://schema.org');
+    assert.equal(ld['@type'], 'Person');
+    assert.equal(ld.name, 'Astrid Mauve');
+    assert.equal(ld.url, 'https://wallco.ai/designers/astrid-mauve');
+    assert.equal(ld.image, 'https://wallco.ai/marketplace/uploads/avatar.jpg');
+    assert.equal(ld.affiliation.name, 'Mauve Studio');
+    assert.equal(ld.address, 'Paris, FR');
+    assert.deepEqual(ld.sameAs, ['https://astridmauve.example', 'https://instagram.com/astridmauve']);
+    assert.deepEqual(ld.knowsAbout, ['botanical', 'maximalist']);
+  });
+
+  test('omits optional fields when missing', () => {
+    const ld = designerJsonLd({ display_name: 'X', slug: 'x' });
+    assert.equal(ld.image, undefined);
+    assert.equal(ld.affiliation, undefined);
+    assert.equal(ld.address, undefined);
+    assert.equal(ld.sameAs, undefined);
+  });
+});
+
+// ---------------------------------------------------------------------------
+// designerMetaBlock
+// ---------------------------------------------------------------------------
+describe('designerMetaBlock', () => {
+  const d = {
+    display_name: 'Astrid Mauve', slug: 'astrid-mauve', status: 'approved',
+    bio: 'Botanical illustrator from Paris.', avatar_url: '/marketplace/uploads/a.jpg',
+    cover_url: '/marketplace/uploads/cover.jpg', studio_name: 'Mauve Studio',
+  };
+  test('contains canonical link, OG tags, twitter card, JSON-LD Person', () => {
+    const html = designerMetaBlock(d);
+    assert.match(html, /<title>Astrid Mauve · Mauve Studio — Wallco Designer<\/title>/);
+    assert.match(html, /<link rel="canonical" href="https:\/\/wallco\.ai\/designers\/astrid-mauve" \/>/);
+    assert.match(html, /<meta property="og:type" content="profile" \/>/);
+    assert.match(html, /<meta property="og:image" content="https:\/\/wallco\.ai\/marketplace\/uploads\/cover\.jpg" \/>/);
+    assert.match(html, /<meta name="twitter:card" content="summary_large_image" \/>/);
+    const ld = firstJsonLd(html);
+    assert.equal(ld['@type'], 'Person');
+    assert.equal(ld.name, 'Astrid Mauve');
+  });
+
+  test('emits robots=noindex when status != approved', () => {
+    const html = designerMetaBlock({ ...d, status: 'pending' });
+    assert.match(html, /<meta name="robots" content="noindex,nofollow" \/>/);
+  });
+
+  test('escapes HTML-special chars in name/bio', () => {
+    const html = designerMetaBlock({
+      display_name: 'A<X>B & "C"', slug: 'a-x-b', status: 'approved',
+      bio: '<script>alert(1)</script>',
+    });
+    assert.ok(!html.includes('<script>alert(1)</script>'));
+    assert.match(html, /A&lt;X&gt;B &amp; &quot;C&quot;/);
+  });
+});
+
+// ---------------------------------------------------------------------------
+// patternJsonLd + patternMetaBlock
+// ---------------------------------------------------------------------------
+describe('patternJsonLd', () => {
+  test('emits Product + CreativeWork with creator pointing at designer', () => {
+    const [product, work] = patternJsonLd(
+      { id: 7, slug: 'p1', title: 'Sun Vines', original_image_url: '/img/p1.jpg',
+        description: 'A floral.', sample_price: 5, base_price: 248,
+        style_tags: ['floral'], motif_tags: ['vines'], color_tags: ['ochre'] },
+      { id: 1, slug: 'astrid-mauve', display_name: 'Astrid Mauve' }
+    );
+    assert.equal(product['@type'], 'Product');
+    assert.equal(product.name, 'Sun Vines');
+    assert.equal(product.sku, 'mp-p1');
+    assert.equal(product.url, 'https://wallco.ai/patterns/p1');
+    assert.equal(product.image, 'https://wallco.ai/img/p1.jpg');
+    assert.equal(product.brand.name, 'Wallco');
+    assert.equal(product.offers['@type'], 'AggregateOffer');
+    assert.equal(product.offers.lowPrice, '5.00');
+    assert.equal(product.offers.highPrice, '248.00');
+
+    assert.equal(work['@type'], 'CreativeWork');
+    assert.equal(work.creator.name, 'Astrid Mauve');
+    assert.equal(work.creator.url, 'https://wallco.ai/designers/astrid-mauve');
+    assert.equal(work.license, 'https://wallco.ai/licensing/p1');
+    assert.equal(work.keywords, 'floral, vines, ochre');
+  });
+
+  test('uses single Offer when only one price tier is set', () => {
+    const [product] = patternJsonLd(
+      { slug: 'p2', title: 'X', original_image_url: '/x.jpg', sample_price: 5 },
+      { slug: 'd', display_name: 'D' }
+    );
+    assert.equal(product.offers['@type'], 'Offer');
+    assert.equal(product.offers.price, '5.00');
+  });
+
+  test('omits offers when no prices set', () => {
+    const [product] = patternJsonLd(
+      { slug: 'p3', title: 'X', original_image_url: '/x.jpg' },
+      { slug: 'd', display_name: 'D' }
+    );
+    assert.equal(product.offers, undefined);
+  });
+});
+
+describe('patternMetaBlock', () => {
+  test('contains two JSON-LD blocks (Product + CreativeWork) + OG product type', () => {
+    const html = patternMetaBlock(
+      { slug: 'p1', title: 'Sun Vines', status: 'approved', original_image_url: '/img/p1.jpg',
+        description: 'A floral.', sample_price: 5, base_price: 248,
+        style_tags: ['floral'], color_tags: ['ochre'] },
+      { slug: 'astrid-mauve', display_name: 'Astrid Mauve' }
+    );
+    assert.match(html, /<title>Sun Vines by Astrid Mauve — Wallco<\/title>/);
+    assert.match(html, /<meta property="og:type" content="product" \/>/);
+    assert.match(html, /<link rel="canonical" href="https:\/\/wallco\.ai\/patterns\/p1" \/>/);
+    assert.match(html, /<meta name="keywords"/);
+    const blocks = allJsonLd(html);
+    assert.equal(blocks.length, 2);
+    assert.equal(blocks[0]['@type'], 'Product');
+    assert.equal(blocks[1]['@type'], 'CreativeWork');
+  });
+
+  test('JSON-LD payload is safe against script-closing injection in title', () => {
+    const html = patternMetaBlock(
+      { slug: 'p1', title: '</script><script>alert(1)</script>', status: 'approved', original_image_url: '/img/p1.jpg' },
+      { slug: 'd', display_name: 'D' }
+    );
+    // The literal closing </script> sequence must NEVER appear inside the
+    // JSON-LD payload — that's the XSS we're guarding against.
+    const ld = html.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g) || [];
+    for (const block of ld) {
+      // strip outer wrapper, then check body
+      const body = block.replace(/^<script[^>]*>/, '').replace(/<\/script>$/, '');
+      assert.ok(!/<\/script>/i.test(body), 'JSON-LD body must not contain </script>');
+    }
+  });
+});
+
+// ---------------------------------------------------------------------------
+// injectIntoHead
+// ---------------------------------------------------------------------------
+describe('injectIntoHead', () => {
+  test('replaces existing <title> and injects before </head>', () => {
+    const base = '<!doctype html><html><head><title>Old</title><meta name="x"></head><body>hi</body></html>';
+    const out = injectIntoHead(base, '<title>New</title>\n<meta name="seo">');
+    assert.ok(out.includes('<title>New</title>'));
+    assert.ok(!out.includes('<title>Old</title>'));
+    // The meta is inserted before </head>.
+    assert.match(out, /<meta name="seo">\n?<\/head>/);
+  });
+
+  test('handles HTML without a <title>', () => {
+    const base = '<!doctype html><html><head><meta charset="utf-8"></head><body>hi</body></html>';
+    const out = injectIntoHead(base, '<title>New</title>');
+    assert.ok(out.includes('<title>New</title>'));
+    assert.ok(out.indexOf('<title>New</title>') < out.indexOf('</head>'));
+  });
+
+  test('handles missing </head> by injecting before <body>', () => {
+    const base = '<html><body>hi</body></html>';
+    const out = injectIntoHead(base, '<title>X</title>');
+    assert.match(out, /<title>X<\/title>\s*<body>/);
+  });
+});

← bd49413 marketplace: admin payout CSV export  ·  back to Wallco Ai  ·  marketplace ai/generate-colorways: dedicated smoke test a1c0a71 →