[object Object]

← back to Wallco Ai

nav: pin marketplace + wallco.ai menu bars to top of viewport, never scroll

d9bcbb078a0409cf4957e4a761d8bbdfff93d4b6 · 2026-05-13 15:32:07 -0700 · SteveStudio2

- public/marketplace/_layout.css: switch .mp-nav from `position: sticky`
  to `position: fixed; top:0; left:0; right:0; z-index:1000` so it can't
  be released by transformed/overflow parents. Add `body { padding-top:
  var(--mp-nav-h, 56px) }` so page content never slides under it.
- public/css/site.css: .site-header was already fixed; bump z-index
  200 → 1000 to match, and add a comment block warning future agents
  that the wallco.ai hero is intentionally designed to sit under the
  translucent header (no body padding-top compensation).

Files touched

Diff

commit d9bcbb078a0409cf4957e4a761d8bbdfff93d4b6
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 13 15:32:07 2026 -0700

    nav: pin marketplace + wallco.ai menu bars to top of viewport, never scroll
    
    - public/marketplace/_layout.css: switch .mp-nav from `position: sticky`
      to `position: fixed; top:0; left:0; right:0; z-index:1000` so it can't
      be released by transformed/overflow parents. Add `body { padding-top:
      var(--mp-nav-h, 56px) }` so page content never slides under it.
    - public/css/site.css: .site-header was already fixed; bump z-index
      200 → 1000 to match, and add a comment block warning future agents
      that the wallco.ai hero is intentionally designed to sit under the
      translucent header (no body padding-top compensation).
---
 public/css/site.css                      |   5 +-
 public/marketplace/_layout.css           |   8 +-
 public/marketplace/_partials.js          |   1 +
 public/marketplace/designer-profile.html |  87 +++++++++
 public/marketplace/designers.html        |  11 +-
 public/marketplace/leaderboard.html      |  15 +-
 public/marketplace/onboard.html          | 312 +++++++++++++++++++++++++++++++
 sql/102_marketplace_onboarding.sql       |  55 ++++++
 src/marketplace/challenges.js            |  21 ++-
 src/marketplace/index.js                 | 111 ++++++++++-
 src/marketplace/onboarding.js            | 182 ++++++++++++++++++
 tests/marketplace/onboarding.test.js     | 167 +++++++++++++++++
 tests/marketplace/smoke.test.js          |  75 +++++++-
 13 files changed, 1032 insertions(+), 18 deletions(-)

diff --git a/public/css/site.css b/public/css/site.css
index 7f8ac2c..1c64d99 100644
--- a/public/css/site.css
+++ b/public/css/site.css
@@ -98,10 +98,13 @@ img { max-width: 100%; display: block; }
 }
 
 /* ── HEADER */
+/* Top nav bar: pinned to viewport on every page, never scrolls.
+   Hero sections are designed to sit *under* this translucent header
+   (backdrop-blur reveal) — do NOT add body padding-top to compensate. */
 .site-header {
   position: fixed;
   top: 0; left: 0; right: 0;
-  z-index: 200;
+  z-index: 1000;
   display: flex;
   justify-content: space-between;
   align-items: center;
diff --git a/public/marketplace/_layout.css b/public/marketplace/_layout.css
index 7878cec..4f6a842 100644
--- a/public/marketplace/_layout.css
+++ b/public/marketplace/_layout.css
@@ -14,15 +14,19 @@
 }
 * { box-sizing: border-box; }
 html, body { margin:0; background: var(--mp-bg); color: var(--mp-fg); font-family: ui-sans-serif, -apple-system, system-ui, "Helvetica Neue", Arial, sans-serif; -webkit-font-smoothing: antialiased; }
+/* Reserve space for the fixed top nav so page content never slides under it. */
+body { padding-top: var(--mp-nav-h, 56px); }
 a { color: inherit; text-decoration: none; }
 a.under { text-decoration: underline; text-underline-offset: 4px; }
 
+/* Top nav: pinned to viewport top on every marketplace page, never scrolls.
+   `fixed` (not `sticky`) so transformed/overflow parents can't release the pin. */
 .mp-nav {
-  position: sticky; top: 0; z-index: 30;
+  position: fixed; top: 0; left: 0; right: 0; z-index: 1000;
   display: flex; align-items: center; justify-content: space-between;
   padding: 14px var(--mp-pad);
   backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
-  background: rgba(250,248,244,.7);
+  background: rgba(250,248,244,.82);
   border-bottom: 1px solid var(--mp-border);
 }
 .mp-nav .brand { font-weight: 700; letter-spacing: .02em; font-size: 17px; }
diff --git a/public/marketplace/_partials.js b/public/marketplace/_partials.js
index 3f82d44..1220db6 100644
--- a/public/marketplace/_partials.js
+++ b/public/marketplace/_partials.js
@@ -10,6 +10,7 @@
       ['/marketplace/leaderboard', 'Leaderboard'],
       ['/marketplace/challenges',  'Challenges'],
       ['/marketplace/apply',     'Become a Designer'],
+      ['/marketplace/onboard',   'Onboard'],
       ['/marketplace/dashboard', 'Dashboard'],
       ['/marketplace/status',    'Status'],
     ];
diff --git a/public/marketplace/designer-profile.html b/public/marketplace/designer-profile.html
index d53ff27..e2ca721 100644
--- a/public/marketplace/designer-profile.html
+++ b/public/marketplace/designer-profile.html
@@ -10,8 +10,93 @@
 <body>
   <main id="root"><div class="mp-wrap"><p>Loading…</p></div></main>
 
+<style>
+  .contact-grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); margin-top: 12px; }
+  .contact-cell { padding: 14px 16px; background: var(--mp-card); border: 1px solid var(--mp-border); border-radius: 16px; }
+  .contact-cell .label { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--mp-muted); margin-bottom: 4px; }
+  .contact-cell .value { font-size: 14px; word-break: break-all; }
+  .contact-cell .value a { color: #0a7; text-decoration: none; }
+  .contact-cell .value a:hover { text-decoration: underline; }
+
+  .social-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; }
+  .social-row a { display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; border-radius: 999px; background: rgba(20,20,20,.06); color: #222; font-size: 13px; }
+  .social-row a:hover { background: rgba(20,20,20,.12); }
+
+  .video-gallery { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(320px,1fr)); }
+  .video-tile { background: var(--mp-card); border: 1px solid var(--mp-border); border-radius: 16px; overflow: hidden; box-shadow: var(--mp-glass-shadow); }
+  .video-tile .frame { position: relative; aspect-ratio: 16/9; background: #000; }
+  .video-tile .frame iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
+  .video-tile .frame a { display:flex; align-items:center; justify-content:center; height:100%; color:#fff; font-size:14px; background-size: cover; background-position: center; }
+  .video-tile .meta { padding: 12px 14px; }
+  .video-tile .meta .ttl { font-weight: 600; font-size: 14px; }
+  .video-tile .meta .desc { font-size: 13px; color: var(--mp-muted); margin-top: 4px; line-height: 1.45; }
+</style>
 <script>
 const slug = location.pathname.split('/').filter(Boolean).pop();
+
+function escapeHtml(s) { return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
+function escapeAttr(s) { return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;'); }
+
+function contactSection(d) {
+  const cells = [];
+  // Website is always public.
+  if (d.website_url) cells.push({ label: 'Website', html: `<a href="${escapeAttr(d.website_url)}" target="_blank" rel="noopener">${escapeHtml(d.website_url.replace(/^https?:\/\//,''))}</a>` });
+  // City/state always public.
+  const cityState = [d.city || d.derived_city, d.state_region || d.derived_state, d.country].filter(Boolean).join(', ');
+  if (cityState) cells.push({ label: 'Location', html: escapeHtml(cityState) });
+  // Contact PII only if consented (server already redacted otherwise).
+  if (d.contact_email) cells.push({ label: 'Email', html: `<a href="mailto:${escapeAttr(d.contact_email)}">${escapeHtml(d.contact_email)}</a>` });
+  if (d.phone) cells.push({ label: 'Phone', html: `<a href="tel:${escapeAttr(d.phone.replace(/[^+\d]/g,''))}">${escapeHtml(d.phone)}</a>` });
+  if (d.address_line1) {
+    const addr = [d.address_line1, d.address_line2, [d.city, d.state_region, d.postal_code].filter(Boolean).join(' ')].filter(Boolean).join('\n');
+    cells.push({ label: 'Studio address', html: escapeHtml(addr).replace(/\n/g,'<br/>') });
+  }
+
+  const socials = [
+    ['instagram_url','Instagram','📸'],
+    ['tiktok_url','TikTok','🎵'],
+    ['pinterest_url','Pinterest','📌'],
+    ['youtube_url','YouTube','▶︎'],
+    ['facebook_url','Facebook','f'],
+    ['twitter_url','X','𝕏'],
+    ['linkedin_url','LinkedIn','in'],
+  ].filter(([key]) => d[key]);
+
+  if (!cells.length && !socials.length) return '';
+
+  const cellsHtml = cells.length
+    ? `<div class="contact-grid">${cells.map(c => `<div class="contact-cell"><div class="label">${c.label}</div><div class="value">${c.html}</div></div>`).join('')}</div>`
+    : '';
+  const socialsHtml = socials.length
+    ? `<div class="social-row">${socials.map(([key,name,icon]) => `<a href="${escapeAttr(d[key])}" target="_blank" rel="noopener"><span style="font-weight:700">${icon}</span> ${name}</a>`).join('')}</div>`
+    : '';
+
+  return `<section class="mp-section"><h2>Contact + social</h2>${cellsHtml}${socialsHtml}</section>`;
+}
+
+function videoSection(videos) {
+  if (!videos || !videos.length) return '';
+  const tiles = videos.map(v => {
+    let frame;
+    if (v.provider === 'youtube' && v.embed_id) {
+      frame = `<iframe src="https://www.youtube.com/embed/${escapeAttr(v.embed_id)}" allowfullscreen allow="autoplay; encrypted-media"></iframe>`;
+    } else if (v.provider === 'vimeo' && v.embed_id) {
+      frame = `<iframe src="https://player.vimeo.com/video/${escapeAttr(v.embed_id)}" allowfullscreen></iframe>`;
+    } else {
+      const thumb = v.thumbnail_url ? `background-image:url(${escapeAttr(v.thumbnail_url)})` : '';
+      frame = `<a href="${escapeAttr(v.url)}" target="_blank" rel="noopener" style="${thumb}">▶︎ Watch on ${escapeHtml(v.provider)}</a>`;
+    }
+    return `<div class="video-tile">
+      <div class="frame">${frame}</div>
+      <div class="meta">
+        <div class="ttl">${escapeHtml(v.title || v.provider + ' video')}</div>
+        ${v.description ? `<div class="desc">${escapeHtml(v.description)}</div>` : ''}
+      </div>
+    </div>`;
+  }).join('');
+  return `<section class="mp-section"><h2>Videos</h2><div class="video-gallery">${tiles}</div></section>`;
+}
+
 async function load() {
   const j = await fetch('/api/marketplace/designers/' + encodeURIComponent(slug)).then(r => r.json());
   const root = document.getElementById('root');
@@ -68,6 +153,8 @@ async function load() {
 
     <div class="mp-wrap">
       ${d.bio ? `<section class="mp-section"><h2>About</h2><p style="max-width: 720px; line-height: 1.6; color: var(--mp-muted);">${d.bio}</p></section>` : ''}
+      ${contactSection(d)}
+      ${videoSection(j.videos)}
       ${(j.collections||[]).length ? `<section class="mp-section">
         <h2>Collections</h2>
         <div class="mp-grid lg">
diff --git a/public/marketplace/designers.html b/public/marketplace/designers.html
index f5c0632..e08f33d 100644
--- a/public/marketplace/designers.html
+++ b/public/marketplace/designers.html
@@ -11,7 +11,8 @@
   .controls select { padding: 8px 12px; border-radius: 999px; border: 1px solid var(--mp-border); background: rgba(255,255,255,.8); font-size: 14px; }
   .controls input[type=range] { flex: 0 0 200px; }
   .mp-card .loc { font-size: 12px; color: var(--mp-muted); display:inline-flex; align-items:center; gap:4px; }
-  .mp-card .loc::before { content: '📍'; font-size: 11px; }
+  .mp-card .loc.weblink { color: #0a7; }
+  .mp-card .loc:not(.weblink)::before { content: '📍'; font-size: 11px; }
 </style>
 </head>
 <body>
@@ -98,13 +99,17 @@ async function load() {
   }
   rows.forEach(d => {
     const a = document.createElement('a'); a.className='mp-card'; a.href=`/designers/${d.slug}`;
-    const locPill = d.location ? `<span class="loc">${d.location}</span>` : '';
+    const locBits = [];
+    if (d.city) locBits.push(d.city);
+    if (d.state && d.state !== d.city) locBits.push(d.state);
+    const locPill = locBits.length ? `<span class="loc">${locBits.join(', ')}</span>` : '';
+    const webHtml = d.website_url ? ` · <span class="loc weblink">🔗 Has site</span>` : '';
     a.innerHTML = `
       <div class="img" style="background: linear-gradient(135deg,#eee,#ddd);"></div>
       <div class="body">
         <div class="title">${d.display_name}</div>
         <div class="sub">${d.studio_name || ''}${d.studio_name && (d.style_tags||[]).length ? ' · ' : ''}${(d.style_tags||[]).slice(0,2).join(' · ')}</div>
-        ${locPill}
+        <div>${locPill}${webHtml}</div>
         <div class="row">
           <span class="mp-pill ${d.is_founding ? 'gold' : ''}">${d.level}</span>
           ${d.is_verified ? '<span class="mp-pill verified">Verified</span>' : ''}
diff --git a/public/marketplace/leaderboard.html b/public/marketplace/leaderboard.html
index c2b6f1b..ee8649e 100644
--- a/public/marketplace/leaderboard.html
+++ b/public/marketplace/leaderboard.html
@@ -32,6 +32,8 @@
   .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 .weblink { color: #0a7; text-decoration: none; }
+  .lb-row .weblink:hover { text-decoration: underline; }
   .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); }
@@ -91,6 +93,10 @@ function fmtScore(metric, score) {
   return n.toLocaleString(undefined, { maximumFractionDigits: metric === 'points' ? 0 : 0 });
 }
 
+function escapeAttr(s) {
+  return String(s || '').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;');
+}
+
 async function loadBoard(metric) {
   const meta = document.getElementById('meta');
   const board = document.getElementById('board');
@@ -112,13 +118,20 @@ async function loadBoard(metric) {
     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>');
+    const locBits = [];
+    if (r.city) locBits.push(r.city);
+    if (r.state && r.state !== r.city) locBits.push(r.state);
+    const locLine = locBits.join(', ');
+    const webLink = r.website_url
+      ? `<a href="${escapeAttr(r.website_url)}" target="_blank" rel="noopener" class="weblink" title="${escapeAttr(r.website_url)}">🔗 Site</a>`
+      : '';
     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 class="level">${r.level || ''}${locLine ? ' · ' + locLine : ''}${webLink ? ' · ' + webLink : ''}</div>
         </div>
         <div class="pills">${pills.join('')}</div>
       </div>
diff --git a/public/marketplace/onboard.html b/public/marketplace/onboard.html
new file mode 100644
index 0000000..440c306
--- /dev/null
+++ b/public/marketplace/onboard.html
@@ -0,0 +1,312 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<title>Wallco · Designer Onboarding</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>
+  .ob-wrap { max-width: 880px; margin: 0 auto; padding: var(--mp-pad); }
+  .ob-hero { padding: clamp(32px,4vw,72px) var(--mp-pad) 12px; }
+  .ob-hero h1 { font-size: clamp(30px,3.6vw,48px); margin: 0 0 8px; letter-spacing: -.02em; }
+  .ob-hero .lede { color: var(--mp-muted); max-width: 700px; line-height: 1.55; }
+
+  .ob-progress { display: flex; align-items: center; gap: 14px; margin: 20px 0 18px; }
+  .ob-progress .bar { flex: 1; height: 6px; background: rgba(20,20,20,.08); border-radius: 999px; overflow: hidden; }
+  .ob-progress .bar > div { height: 100%; background: linear-gradient(90deg,#0a7,#27c97a); border-radius: 999px; transition: width .25s ease; }
+  .ob-progress .pct { font-weight: 700; font-size: 14px; min-width: 48px; text-align: right; }
+
+  .ob-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 18px; }
+  .ob-tab { padding: 8px 14px; border-radius: 999px; background: rgba(20,20,20,.06); color: #444; cursor: pointer; font-size: 13px; font-weight: 600; border: 0; }
+  .ob-tab.active { background: #111; color: #fff; }
+  .ob-tab.done::after { content: ' ✓'; color: #0a7; }
+  .ob-tab.active.done::after { color: #5fe39a; }
+
+  .ob-card { padding: 22px 24px; background: var(--mp-card); border: 1px solid var(--mp-border); border-radius: 22px; box-shadow: var(--mp-glass-shadow); }
+  .ob-card h2 { font-size: 22px; margin: 0 0 6px; letter-spacing: -.01em; }
+  .ob-card .sub { color: var(--mp-muted); font-size: 14px; margin-bottom: 18px; line-height: 1.5; }
+
+  .field-grid { display: grid; gap: 14px; grid-template-columns: 1fr 1fr; }
+  .field-grid label { display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: #333; }
+  .field-grid label.full { grid-column: 1 / -1; }
+  .field-grid input, .field-grid textarea, .field-grid select {
+    border: 1px solid var(--mp-border); padding: 10px 12px; border-radius: 12px; font-size: 14px; font-family: inherit; background: #fff;
+  }
+  .field-grid textarea { min-height: 96px; resize: vertical; }
+  @media (max-width: 600px) { .field-grid { grid-template-columns: 1fr; } }
+
+  .ob-row { display: flex; justify-content: space-between; align-items: center; margin-top: 20px; gap: 10px; flex-wrap: wrap; }
+  .ob-row .msg { font-size: 13px; color: var(--mp-muted); }
+  .ob-row .msg.ok { color: #060; }
+  .ob-row .msg.err { color: #c33; }
+
+  .video-list { display: grid; gap: 12px; margin-top: 16px; }
+  .video-row { display: grid; grid-template-columns: 120px 1fr auto; gap: 12px; padding: 12px; background: rgba(20,20,20,.03); border-radius: 12px; align-items: center; }
+  .video-row .thumb { aspect-ratio: 16/9; background: #ccc; background-size: cover; background-position: center; border-radius: 8px; }
+  .video-row .vmeta .title { font-weight: 600; font-size: 14px; }
+  .video-row .vmeta .url { font-size: 12px; color: var(--mp-muted); word-break: break-all; }
+  .video-row .vmeta .badges { margin-top: 4px; }
+  .video-row .vmeta .badge { display:inline-block; padding: 2px 8px; border-radius: 999px; background: rgba(20,20,20,.06); font-size: 11px; }
+  .video-row .vmeta .badge.pending { background: #fee; color: #944; }
+  .video-row .vmeta .badge.approved { background: #def; color: #084; }
+  .video-row .del { background: transparent; border: 1px solid var(--mp-border); padding: 6px 10px; border-radius: 8px; cursor: pointer; font-size: 12px; }
+  .video-row .del:hover { background: #fee; border-color: #f99; color: #944; }
+</style>
+</head>
+<body>
+  <header class="ob-hero ob-wrap">
+    <h1>Onboard your designer profile</h1>
+    <p class="lede">Four short steps to publish your storefront. You can save progress and come back any time — your work auto-saves after each step.</p>
+    <div class="ob-progress">
+      <div class="bar"><div id="bar-fill" style="width:0%"></div></div>
+      <div class="pct" id="pct">0%</div>
+    </div>
+  </header>
+
+  <main class="ob-wrap">
+    <div class="ob-tabs" id="tabs"></div>
+    <div id="step-host"></div>
+  </main>
+
+<template id="step-profile">
+  <div class="ob-card">
+    <h2>1 · Profile basics</h2>
+    <p class="sub">Make your storefront recognisable. Your studio name and bio show on every product page and at the top of your storefront.</p>
+    <form class="field-grid" data-step="profile">
+      <label class="full">Studio name<input name="studio_name" type="text" maxlength="100" placeholder="e.g. Mauve Atelier"/></label>
+      <label class="full">Bio (2-3 sentences)<textarea name="bio" maxlength="2000" placeholder="What kind of patterns do you create? What inspires you?"></textarea></label>
+      <label>Avatar URL<input name="avatar_url" type="url" maxlength="500" placeholder="https://…"/></label>
+      <label>Cover image URL<input name="cover_url" type="url" maxlength="500" placeholder="https://…"/></label>
+    </form>
+  </div>
+</template>
+
+<template id="step-web">
+  <div class="ob-card">
+    <h2>2 · Website + social</h2>
+    <p class="sub">Designers with a public website get a ranking boost on the Stars of Design leaderboard. Add anything you'd like buyers to discover.</p>
+    <form class="field-grid" data-step="web">
+      <label class="full">Website<input name="website_url" type="url" maxlength="300" placeholder="https://yourstudio.com"/></label>
+      <label>Instagram<input name="instagram_url" type="url" maxlength="300" placeholder="https://instagram.com/…"/></label>
+      <label>TikTok<input name="tiktok_url" type="url" maxlength="300" placeholder="https://tiktok.com/@…"/></label>
+      <label>Pinterest<input name="pinterest_url" type="url" maxlength="300" placeholder="https://pinterest.com/…"/></label>
+      <label>YouTube<input name="youtube_url" type="url" maxlength="300" placeholder="https://youtube.com/@…"/></label>
+      <label>Facebook<input name="facebook_url" type="url" maxlength="300" placeholder="https://facebook.com/…"/></label>
+      <label>Twitter / X<input name="twitter_url" type="url" maxlength="300" placeholder="https://x.com/…"/></label>
+      <label>LinkedIn<input name="linkedin_url" type="url" maxlength="300" placeholder="https://linkedin.com/in/…"/></label>
+    </form>
+  </div>
+</template>
+
+<template id="step-contact">
+  <div class="ob-card">
+    <h2>3 · Contact + address</h2>
+    <p class="sub">Trade buyers, licensees, and our team use this to reach you about commissions and orders. Toggle whether to show this publicly on your profile.</p>
+    <form class="field-grid" data-step="contact">
+      <label>Public contact email<input name="contact_email" type="email" maxlength="200" placeholder="hello@yourstudio.com"/></label>
+      <label>Phone<input name="phone" type="tel" maxlength="50" placeholder="+1 555 555 5555"/></label>
+      <label class="full">Address line 1<input name="address_line1" type="text" maxlength="200" placeholder="123 Studio Lane"/></label>
+      <label class="full">Address line 2<input name="address_line2" type="text" maxlength="200" placeholder="Suite 4 (optional)"/></label>
+      <label>City<input name="city" type="text" maxlength="100"/></label>
+      <label>State / Region<input name="state_region" type="text" maxlength="100"/></label>
+      <label>Postal code<input name="postal_code" type="text" maxlength="30"/></label>
+      <label>Country<input name="country" type="text" maxlength="100" placeholder="USA"/></label>
+      <label class="full" style="flex-direction:row;align-items:center;gap:10px">
+        <input name="public_contact_consent" type="checkbox"/>
+        <span style="font-size:13px">Show my contact info publicly on my designer profile</span>
+      </label>
+    </form>
+  </div>
+</template>
+
+<template id="step-videos">
+  <div class="ob-card">
+    <h2>4 · Videos</h2>
+    <p class="sub">Paste a YouTube, Vimeo, TikTok, or Instagram link. We auto-detect the embed. Videos go through quick review before showing publicly.</p>
+    <form class="field-grid" data-step="add-video">
+      <label class="full">Video URL<input name="url" type="url" maxlength="500" placeholder="https://youtube.com/watch?v=…" required/></label>
+      <label class="full">Title (optional)<input name="title" type="text" maxlength="200" placeholder="Studio tour, pattern process, brand intro…"/></label>
+      <label class="full">Description (optional)<textarea name="description" maxlength="2000"></textarea></label>
+      <label class="full" style="flex-direction:row;align-items:center;gap:10px">
+        <input name="is_featured" type="checkbox"/>
+        <span style="font-size:13px">Feature this video on my profile</span>
+      </label>
+    </form>
+    <div class="ob-row" style="margin-top:14px">
+      <button class="mp-btn" id="add-video-btn">Add video</button>
+    </div>
+    <div class="video-list" id="video-list"></div>
+  </div>
+</template>
+
+<script>
+const STEPS = ['profile','web','contact','videos'];
+const LABELS = { profile: 'Profile', web: 'Website + social', contact: 'Contact', videos: 'Videos' };
+let me = null;
+let completeness = null;
+
+function tabsHtml(activeStep, doneMap) {
+  const wrap = document.getElementById('tabs');
+  wrap.innerHTML = '';
+  STEPS.forEach((step, i) => {
+    const btn = document.createElement('button');
+    btn.className = 'ob-tab' + (step === activeStep ? ' active' : '') + (doneMap[step] ? ' done' : '');
+    btn.textContent = `${i+1} · ${LABELS[step]}`;
+    btn.addEventListener('click', () => renderStep(step));
+    wrap.appendChild(btn);
+  });
+}
+
+function setPct(pct) {
+  document.getElementById('bar-fill').style.width = pct + '%';
+  document.getElementById('pct').textContent = pct + '%';
+}
+
+async function loadMe() {
+  try {
+    const r = await fetch('/api/marketplace/designer/me', { credentials: 'include' });
+    if (r.status === 401) { location.href = '/marketplace/apply?return=/marketplace/onboard'; return; }
+    const j = await r.json();
+    me = j.designer;
+    completeness = await fetch('/api/marketplace/designer/me/completeness', { credentials: 'include' }).then(r => r.json());
+    setPct(completeness.pct || 0);
+    const doneMap = {};
+    (completeness.steps || []).forEach(s => { doneMap[s.key] = s.done; });
+    // Map onboarding STEPS (server-side) onto wizard STEPS (web/contact/videos).
+    // server returns: profile, web, contact, videos, rights → we map directly except rights (auto on apply).
+    tabsHtml(STEPS[0], doneMap);
+    renderStep(STEPS[0]);
+  } catch (e) {
+    document.getElementById('step-host').innerHTML = '<div class="ob-card"><p>Could not load your profile. <a class="under" href="/marketplace/apply">Apply first</a> or refresh.</p></div>';
+  }
+}
+
+function fillFormFromMe(form, fields) {
+  fields.forEach(name => {
+    const el = form.elements[name];
+    if (!el || !me) return;
+    if (el.type === 'checkbox') el.checked = !!me[name];
+    else el.value = me[name] || '';
+  });
+}
+
+function renderStep(step) {
+  const host = document.getElementById('step-host');
+  host.innerHTML = '';
+  const tmpl = document.getElementById('step-' + step);
+  host.appendChild(tmpl.content.cloneNode(true));
+  const form = host.querySelector('form');
+  if (step === 'profile') fillFormFromMe(form, ['studio_name','bio','avatar_url','cover_url']);
+  if (step === 'web')     fillFormFromMe(form, ['website_url','instagram_url','tiktok_url','pinterest_url','youtube_url','facebook_url','twitter_url','linkedin_url']);
+  if (step === 'contact') fillFormFromMe(form, ['contact_email','phone','address_line1','address_line2','city','state_region','postal_code','country','public_contact_consent']);
+
+  if (step !== 'videos') {
+    // Auto-save handler
+    const ob = document.createElement('div');
+    ob.className = 'ob-row';
+    const idx = STEPS.indexOf(step);
+    const nextBtn = `<button class="mp-btn" id="save-next">${idx < STEPS.length - 1 ? 'Save & continue →' : 'Save'}</button>`;
+    ob.innerHTML = `<div class="msg" id="msg"></div>${nextBtn}`;
+    host.querySelector('.ob-card').appendChild(ob);
+    document.getElementById('save-next').addEventListener('click', async (e) => {
+      e.preventDefault();
+      const payload = {};
+      Array.from(form.elements).forEach(el => {
+        if (!el.name) return;
+        if (el.type === 'checkbox') payload[el.name] = el.checked;
+        else payload[el.name] = el.value;
+      });
+      const msg = document.getElementById('msg');
+      msg.textContent = 'Saving…'; msg.className = 'msg';
+      try {
+        const r = await fetch('/api/marketplace/designer/me', {
+          method: 'PATCH', credentials: 'include',
+          headers: { 'content-type': 'application/json' },
+          body: JSON.stringify(payload),
+        });
+        const j = await r.json();
+        if (!r.ok) throw new Error(j.error || 'save failed');
+        me = j.designer;
+        setPct(j.completeness?.pct || 0);
+        const doneMap = {};
+        (j.completeness?.steps || []).forEach(s => { doneMap[s.key] = s.done; });
+        msg.textContent = 'Saved ✓'; msg.className = 'msg ok';
+        if (idx < STEPS.length - 1) {
+          setTimeout(() => { tabsHtml(STEPS[idx+1], doneMap); renderStep(STEPS[idx+1]); }, 350);
+        } else {
+          tabsHtml(step, doneMap);
+        }
+      } catch (err) {
+        msg.textContent = 'Could not save: ' + err.message; msg.className = 'msg err';
+      }
+    });
+  } else {
+    // Videos step
+    document.getElementById('add-video-btn').addEventListener('click', addVideo);
+    refreshVideos();
+  }
+}
+
+async function refreshVideos() {
+  const list = document.getElementById('video-list');
+  list.innerHTML = '<div class="sub">Loading…</div>';
+  const j = await fetch('/api/marketplace/designer/me/videos', { credentials: 'include' }).then(r => r.json()).catch(() => ({ videos: [] }));
+  list.innerHTML = '';
+  if (!j.videos || !j.videos.length) {
+    list.innerHTML = '<div class="sub">No videos yet. Paste a URL above to add your first.</div>';
+    return;
+  }
+  j.videos.forEach(v => {
+    const row = document.createElement('div');
+    row.className = 'video-row';
+    let thumb = '';
+    if (v.provider === 'youtube' && v.embed_id) thumb = `background-image:url(https://i.ytimg.com/vi/${v.embed_id}/mqdefault.jpg)`;
+    row.innerHTML = `
+      <div class="thumb" style="${thumb}"></div>
+      <div class="vmeta">
+        <div class="title">${v.title || v.url}</div>
+        <div class="url">${v.url}</div>
+        <div class="badges"><span class="badge ${v.status}">${v.status}</span> ${v.is_featured ? '<span class="badge">Featured</span>' : ''} <span class="badge">${v.provider}</span></div>
+      </div>
+      <button class="del" data-id="${v.id}">Remove</button>`;
+    row.querySelector('.del').addEventListener('click', async () => {
+      if (!confirm('Remove this video?')) return;
+      await fetch('/api/marketplace/designer/me/videos/' + v.id, { method: 'DELETE', credentials: 'include' });
+      refreshVideos();
+    });
+    list.appendChild(row);
+  });
+}
+
+async function addVideo() {
+  const form = document.querySelector('form[data-step=add-video]');
+  const url = form.elements.url.value.trim();
+  if (!url) { alert('Paste a video URL first'); return; }
+  const payload = {
+    url, title: form.elements.title.value, description: form.elements.description.value,
+    is_featured: form.elements.is_featured.checked,
+  };
+  const r = await fetch('/api/marketplace/designer/me/videos', {
+    method: 'POST', credentials: 'include',
+    headers: { 'content-type': 'application/json' },
+    body: JSON.stringify(payload),
+  });
+  const j = await r.json();
+  if (!r.ok) { alert(j.error || 'Could not add video'); return; }
+  form.reset();
+  refreshVideos();
+  // refresh completeness
+  const c = await fetch('/api/marketplace/designer/me/completeness', { credentials: 'include' }).then(r => r.json()).catch(() => null);
+  if (c) {
+    setPct(c.pct || 0);
+    const doneMap = {};
+    (c.steps || []).forEach(s => { doneMap[s.key] = s.done; });
+    tabsHtml('videos', doneMap);
+  }
+}
+
+document.addEventListener('DOMContentLoaded', loadMe);
+</script>
+</body>
+</html>
diff --git a/sql/102_marketplace_onboarding.sql b/sql/102_marketplace_onboarding.sql
new file mode 100644
index 0000000..3e3978b
--- /dev/null
+++ b/sql/102_marketplace_onboarding.sql
@@ -0,0 +1,55 @@
+-- Wallco Marketplace — designer onboarding: structured contact, address,
+-- social handles, and a video gallery. Designers can opt-in to making each
+-- field public via mp_designer_profiles.public_contact_consent.
+-- 2026-05-13 — additive only, safe to re-run.
+
+BEGIN;
+
+-- Extend mp_designer_profiles with structured fields. Old free-text `location`
+-- is preserved as a fallback display string until profiles backfill the
+-- structured columns.
+ALTER TABLE mp_designer_profiles
+  ADD COLUMN IF NOT EXISTS phone TEXT,
+  ADD COLUMN IF NOT EXISTS contact_email TEXT,
+  ADD COLUMN IF NOT EXISTS address_line1 TEXT,
+  ADD COLUMN IF NOT EXISTS address_line2 TEXT,
+  ADD COLUMN IF NOT EXISTS city TEXT,
+  ADD COLUMN IF NOT EXISTS state_region TEXT,
+  ADD COLUMN IF NOT EXISTS postal_code TEXT,
+  ADD COLUMN IF NOT EXISTS country TEXT,
+  ADD COLUMN IF NOT EXISTS twitter_url TEXT,
+  ADD COLUMN IF NOT EXISTS tiktok_url TEXT,
+  ADD COLUMN IF NOT EXISTS pinterest_url TEXT,
+  ADD COLUMN IF NOT EXISTS facebook_url TEXT,
+  ADD COLUMN IF NOT EXISTS linkedin_url TEXT,
+  ADD COLUMN IF NOT EXISTS youtube_url TEXT,
+  ADD COLUMN IF NOT EXISTS public_contact_consent BOOLEAN NOT NULL DEFAULT false,
+  ADD COLUMN IF NOT EXISTS onboarding_completed_at TIMESTAMPTZ,
+  ADD COLUMN IF NOT EXISTS onboarding_steps JSONB NOT NULL DEFAULT '{}'::JSONB;
+
+-- Video gallery (YouTube/Vimeo embeds, designer-curated)
+CREATE TABLE IF NOT EXISTS mp_designer_videos (
+  id BIGSERIAL PRIMARY KEY,
+  designer_id BIGINT NOT NULL REFERENCES mp_designer_profiles(id) ON DELETE CASCADE,
+  url TEXT NOT NULL,
+  provider TEXT NOT NULL DEFAULT 'youtube' CHECK (provider IN ('youtube','vimeo','tiktok','instagram','direct')),
+  title TEXT,
+  description TEXT,
+  thumbnail_url TEXT,
+  embed_id TEXT,            -- e.g. YouTube video ID; populated when known
+  duration_seconds INTEGER,
+  is_featured BOOLEAN NOT NULL DEFAULT false,
+  display_order INTEGER NOT NULL DEFAULT 0,
+  status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected','archived')),
+  rejection_reason TEXT,
+  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS mp_designer_videos_designer_idx ON mp_designer_videos(designer_id);
+CREATE INDEX IF NOT EXISTS mp_designer_videos_status_idx ON mp_designer_videos(status);
+
+DROP TRIGGER IF EXISTS mp_designer_videos_touch ON mp_designer_videos;
+CREATE TRIGGER mp_designer_videos_touch BEFORE UPDATE ON mp_designer_videos
+  FOR EACH ROW EXECUTE FUNCTION mp_touch_updated_at();
+
+COMMIT;
diff --git a/src/marketplace/challenges.js b/src/marketplace/challenges.js
index 7af069a..5066764 100644
--- a/src/marketplace/challenges.js
+++ b/src/marketplace/challenges.js
@@ -54,12 +54,17 @@ function buildLeaderboardQuery(metric, { since = null, until = null, limit = 50
          ORDER BY score DESC, d.display_name ASC
          LIMIT ${p(lim)}`;
     } else {
+      // "Stars of Design" tiebreaker: designers with a public website_url rank ahead of
+      // those without, when points are equal. Underlying score is still raw points.
       sql = `
         SELECT id AS designer_id, slug, display_name, avatar_url, level, is_founding, is_verified,
+               location, website_url, instagram_url,
                points::numeric AS score
           FROM mp_designer_profiles
          WHERE status='approved'
-         ORDER BY points DESC, display_name ASC
+         ORDER BY points DESC,
+                  CASE WHEN website_url IS NOT NULL AND website_url <> '' THEN 0 ELSE 1 END ASC,
+                  display_name ASC
          LIMIT ${p(lim)}`;
     }
   } else if (metric === 'sales') {
@@ -127,6 +132,15 @@ async function getLeaderboard(opts = {}) {
       prevRank = rank;
       prevScore = score;
     }
+    // Heuristic city/state split from free-text location.
+    let city = null, state = null;
+    if (r.location && typeof r.location === 'string' && r.location.indexOf(',') >= 0) {
+      const parts = r.location.split(',').map((s) => s.trim()).filter(Boolean);
+      city = parts.slice(0, -1).join(', ') || null;
+      state = parts[parts.length - 1] || null;
+    } else if (r.location) {
+      city = r.location;
+    }
     return {
       rank,
       designer_id: Number(r.designer_id),
@@ -136,6 +150,11 @@ async function getLeaderboard(opts = {}) {
       level: r.level,
       is_founding: !!r.is_founding,
       is_verified: !!r.is_verified,
+      location: r.location || null,
+      city,
+      state,
+      website_url: r.website_url || null,
+      instagram_url: r.instagram_url || null,
       score,
     };
   });
diff --git a/src/marketplace/index.js b/src/marketplace/index.js
index db84026..69e2d8e 100644
--- a/src/marketplace/index.js
+++ b/src/marketplace/index.js
@@ -42,6 +42,7 @@ const payouts = require('./payouts');
 const gam = require('./gamification');
 const seo = require('./seo');
 const challenges = require('./challenges');
+const onboarding = require('./onboarding');
 const orders = require('./orders');
 
 // ── Designer cookie helpers (lightweight HMAC; trust-only, not crypto-grade)
@@ -270,19 +271,21 @@ function mount(app) {
   });
 
   // ── designer list (public)
+  // "Stars of Design" ranking — default sort gives a +100 effective-points boost to designers
+  // who have a public website_url. Boosts ranking only; underlying points column is not modified.
+  // Pass ?boost=0 to disable the website boost (e.g. for admin diagnostics).
   app.get('/api/marketplace/designers', async (req, res) => {
     const limit = Math.min(parseInt(req.query.limit, 10) || 60, 200);
     const offset = parseInt(req.query.offset, 10) || 0;
+    const websiteBoost = req.query.boost === '0' ? '0' : '100';
+    const boostExpr = `(points + CASE WHEN website_url IS NOT NULL AND website_url <> '' THEN ${websiteBoost} ELSE 0 END)`;
     const sort = ({
       newest: 'created_at DESC',
-      points: 'points DESC',
-      featured: 'is_founding DESC, points DESC',
-      // Location: alphabetical, with locationless designers at the end. Secondary sort by name
-      // so the order is stable within a city.
+      points: `${boostExpr} DESC, display_name ASC`,
+      featured: `is_founding DESC, ${boostExpr} DESC, display_name ASC`,
       location: "location ASC NULLS LAST, display_name ASC",
-    }[req.query.sort] || 'points DESC');
+    }[req.query.sort] || `${boostExpr} DESC, display_name ASC`);
 
-    // Optional ?location=Brooklyn filter — case-insensitive substring.
     const where = ["status='approved'"];
     const params = [];
     const locFilter = (req.query.location || '').toString().trim();
@@ -295,14 +298,26 @@ function mount(app) {
 
     const rows = await many(
       `SELECT id, display_name, slug, studio_name, avatar_url, cover_url, level, points,
-              is_verified, is_founding, is_claimed, style_tags, location
+              is_verified, is_founding, is_claimed, style_tags, location, website_url, instagram_url
          FROM mp_designer_profiles
         WHERE ${where.join(' AND ')}
         ORDER BY ${sort}
         LIMIT $${limIdx} OFFSET $${offIdx}`,
       params
     );
-    res.json({ designers: rows, limit, offset, sort: req.query.sort || 'points', location: locFilter || null });
+    // Heuristic city/state split — location is free text ("Brooklyn, NY", "Los Angeles, CA",
+    // "Paris, France"). Take the last comma-separated chunk as state/region.
+    rows.forEach((d) => {
+      if (d.location && typeof d.location === 'string' && d.location.indexOf(',') >= 0) {
+        const parts = d.location.split(',').map((s) => s.trim()).filter(Boolean);
+        d.city = parts.slice(0, -1).join(', ') || null;
+        d.state = parts[parts.length - 1] || null;
+      } else {
+        d.city = d.location || null;
+        d.state = null;
+      }
+    });
+    res.json({ designers: rows, limit, offset, sort: req.query.sort || 'points', location: locFilter || null, website_boost: Number(websiteBoost) });
   });
 
   // ── distinct locations (public; powers the designers-page location filter)
@@ -319,6 +334,9 @@ function mount(app) {
   });
 
   // ── designer detail (public)
+  // Redacts contact PII (phone, email, postal address) unless the designer has set
+  // public_contact_consent=true. Website + city/state/country always public — they're
+  // the basics that make the designer findable.
   app.get('/api/marketplace/designers/:slug', async (req, res) => {
     const d = await one(`SELECT * FROM mp_designer_profiles WHERE slug=$1`, [req.params.slug]);
     if (d && d.status === 'approved') {
@@ -329,7 +347,17 @@ function mount(app) {
       );
       const badges = await many(`SELECT badge_key, badge_name, icon FROM mp_designer_badges WHERE designer_id=$1 ORDER BY awarded_at`, [d.id]);
       const collections = await many(`SELECT id, title, slug, cover_image_url FROM mp_collections WHERE designer_id=$1 AND status='published' ORDER BY created_at DESC`, [d.id]);
-      return res.json({ designer: d, patterns, badges, collections, claim: null });
+      const videos = await onboarding.listVideos(d.id, { onlyApproved: true }).catch(() => []);
+      if (!d.public_contact_consent) {
+        ['phone','contact_email','address_line1','address_line2','postal_code'].forEach((k) => { d[k] = null; });
+      }
+      // Heuristic city/state split for cards/grids that consume designer detail directly.
+      if (d.location && typeof d.location === 'string' && d.location.indexOf(',') >= 0) {
+        const parts = d.location.split(',').map((s) => s.trim()).filter(Boolean);
+        d.derived_city = parts.slice(0, -1).join(', ') || null;
+        d.derived_state = parts[parts.length - 1] || null;
+      }
+      return res.json({ designer: d, patterns, badges, collections, videos, claim: null });
     }
     // unclaimed?
     const claim = await one(`SELECT * FROM mp_designer_claim_pages WHERE slug=$1`, [req.params.slug]);
@@ -803,6 +831,70 @@ function mount(app) {
     }
   });
 
+  // ── Onboarding — designer self-update, video gallery, completeness %
+  app.patch('/api/marketplace/designer/me', requireDesigner, async (req, res) => {
+    try {
+      const row = await onboarding.updateDesignerProfile(req.designer.id, req.body || {});
+      const completeness = await onboarding.computeCompleteness(req.designer.id);
+      res.json({ ok: true, designer: row, completeness });
+    } catch (err) {
+      console.error('[designer patch]', err);
+      res.status(500).json({ error: err.message });
+    }
+  });
+
+  app.get('/api/marketplace/designer/me/completeness', requireDesigner, async (req, res) => {
+    try {
+      const completeness = await onboarding.computeCompleteness(req.designer.id);
+      res.json(completeness || { error: 'designer not found' });
+    } catch (err) { res.status(500).json({ error: err.message }); }
+  });
+
+  // ── Video gallery (designer self)
+  app.get('/api/marketplace/designer/me/videos', requireDesigner, async (req, res) => {
+    try {
+      const videos = await onboarding.listVideos(req.designer.id);
+      res.json({ videos });
+    } catch (err) { res.status(500).json({ error: err.message }); }
+  });
+
+  app.post('/api/marketplace/designer/me/videos', requireDesigner, async (req, res) => {
+    try {
+      const v = await onboarding.addVideo(req.designer.id, req.body || {});
+      res.json({ ok: true, video: v });
+    } catch (err) {
+      const status = /required|invalid/i.test(err.message) ? 400 : 500;
+      res.status(status).json({ error: err.message });
+    }
+  });
+
+  app.delete('/api/marketplace/designer/me/videos/:id', requireDesigner, async (req, res) => {
+    try {
+      const vid = parseInt(req.params.id, 10);
+      if (!vid) return res.status(400).json({ error: 'invalid id' });
+      await onboarding.deleteVideo(req.designer.id, vid);
+      res.json({ ok: true });
+    } catch (err) { res.status(500).json({ error: err.message }); }
+  });
+
+  app.post('/api/marketplace/designer/me/videos/reorder', requireDesigner, async (req, res) => {
+    try {
+      const order = (req.body && req.body.order) || [];
+      await onboarding.reorderVideos(req.designer.id, order);
+      res.json({ ok: true });
+    } catch (err) { res.status(500).json({ error: err.message }); }
+  });
+
+  // ── Public videos for a designer (only approved videos surfaced)
+  app.get('/api/marketplace/designers/:slug/videos', async (req, res) => {
+    try {
+      const d = await one(`SELECT id FROM mp_designer_profiles WHERE slug=$1 AND status='approved'`, [req.params.slug]);
+      if (!d) return res.status(404).json({ error: 'not found' });
+      const videos = await onboarding.listVideos(d.id, { onlyApproved: true });
+      res.json({ videos });
+    } catch (err) { res.status(500).json({ error: err.message }); }
+  });
+
   // ── AI stubs
   require('./ai').mount(app);
   require('./textures').mount(app);
@@ -888,6 +980,7 @@ function mount(app) {
   app.get('/licensing/:slug', sendPage('licensing.html'));
   app.get('/marketplace/claim', sendPage('claim.html'));
   app.get('/marketplace/leaderboard', sendPage('leaderboard.html'));
+  app.get('/marketplace/onboard', sendPage('onboard.html'));
   app.get('/marketplace/challenges', sendPage('challenges.html'));
   app.get('/marketplace/challenges/:id', sendPage('challenge.html'));
   app.get('/marketplace/projects', sendPage('projects.html'));
diff --git a/src/marketplace/onboarding.js b/src/marketplace/onboarding.js
new file mode 100644
index 0000000..fc1e18d
--- /dev/null
+++ b/src/marketplace/onboarding.js
@@ -0,0 +1,182 @@
+// Wallco Marketplace — designer onboarding helpers.
+// Owns the self-service PATCH endpoint, video CRUD, and the completeness %.
+
+const { q, one, many } = require('./db');
+const { safeText } = require('./util');
+
+// Editable profile columns (allowlist). Anything not in this list is ignored
+// on PATCH; this prevents accidentally writing admin fields (status, is_founding,
+// commission_rate, etc.) from a designer-authored payload.
+const PROFILE_UPDATABLE = [
+  'studio_name', 'bio', 'website_url', 'instagram_url', 'location',
+  'avatar_url', 'cover_url',
+  'phone', 'contact_email',
+  'address_line1', 'address_line2', 'city', 'state_region', 'postal_code', 'country',
+  'twitter_url', 'tiktok_url', 'pinterest_url', 'facebook_url', 'linkedin_url', 'youtube_url',
+  'public_contact_consent',
+];
+
+// Field length caps to keep buffers sane.
+const FIELD_MAX = {
+  studio_name: 100, bio: 2000, website_url: 300, instagram_url: 300, location: 150,
+  avatar_url: 500, cover_url: 500,
+  phone: 50, contact_email: 200,
+  address_line1: 200, address_line2: 200, city: 100, state_region: 100, postal_code: 30, country: 100,
+  twitter_url: 300, tiktok_url: 300, pinterest_url: 300, facebook_url: 300, linkedin_url: 300, youtube_url: 300,
+};
+
+function normalizeUrl(u) {
+  const s = String(u || '').trim();
+  if (!s) return null;
+  if (!/^https?:\/\//i.test(s)) return 'https://' + s;
+  return s;
+}
+
+function normalizePayload(body) {
+  const out = {};
+  for (const key of PROFILE_UPDATABLE) {
+    if (!(key in body)) continue;
+    let v = body[key];
+    if (v === null || v === '') { out[key] = null; continue; }
+    if (key === 'public_contact_consent') { out[key] = v === true || v === 'true'; continue; }
+    if (/_url$/.test(key)) { out[key] = normalizeUrl(v); continue; }
+    const max = FIELD_MAX[key] || 1000;
+    out[key] = safeText(v, max);
+  }
+  return out;
+}
+
+async function updateDesignerProfile(designerId, body) {
+  const fields = normalizePayload(body);
+  if (!Object.keys(fields).length) {
+    return one(`SELECT * FROM mp_designer_profiles WHERE id=$1`, [designerId]);
+  }
+  const sets = [];
+  const params = [];
+  for (const [k, v] of Object.entries(fields)) {
+    params.push(v);
+    sets.push(`${k} = $${params.length}`);
+  }
+  params.push(designerId);
+  return one(
+    `UPDATE mp_designer_profiles SET ${sets.join(', ')} WHERE id = $${params.length} RETURNING *`,
+    params
+  );
+}
+
+// Onboarding completeness — used by UI to show % and pick what to nudge.
+const STEPS = [
+  { key: 'profile',     label: 'Profile basics',  fields: ['display_name','bio','avatar_url'] },
+  { key: 'web',         label: 'Website + social', fields: ['website_url','instagram_url'] },
+  { key: 'contact',     label: 'Contact + address', fields: ['contact_email','city','country'] },
+  { key: 'videos',      label: 'Add a video',     fields: ['__has_videos__'] },
+  { key: 'rights',      label: 'Sign rights agreement', fields: ['rights_agreement_at'] },
+];
+
+async function computeCompleteness(designerId) {
+  const row = await one(`SELECT * FROM mp_designer_profiles WHERE id=$1`, [designerId]);
+  if (!row) return null;
+  const videoCount = await one(`SELECT COUNT(*)::int AS n FROM mp_designer_videos WHERE designer_id=$1 AND status<>'rejected'`, [designerId]);
+  const has = (f) => {
+    if (f === '__has_videos__') return (videoCount?.n || 0) > 0;
+    return row[f] !== null && row[f] !== '';
+  };
+  const stepResults = STEPS.map((s) => {
+    const done = s.fields.every(has);
+    return { key: s.key, label: s.label, done };
+  });
+  const done = stepResults.filter((x) => x.done).length;
+  const total = stepResults.length;
+  const pct = Math.round((done / total) * 100);
+  return { pct, done, total, steps: stepResults };
+}
+
+// ── Video gallery
+function extractYouTubeId(url) {
+  if (!url) return null;
+  const s = String(url);
+  let m;
+  // Standard watch URL
+  m = s.match(/[?&]v=([A-Za-z0-9_-]{6,})/);
+  if (m) return m[1];
+  // Short URL
+  m = s.match(/youtu\.be\/([A-Za-z0-9_-]{6,})/);
+  if (m) return m[1];
+  // Embed URL
+  m = s.match(/youtube\.com\/embed\/([A-Za-z0-9_-]{6,})/);
+  if (m) return m[1];
+  return null;
+}
+
+function extractVimeoId(url) {
+  if (!url) return null;
+  const m = String(url).match(/vimeo\.com\/(\d+)/);
+  return m ? m[1] : null;
+}
+
+function inferProvider(url) {
+  if (!url) return 'direct';
+  if (/youtube\.com|youtu\.be/i.test(url)) return 'youtube';
+  if (/vimeo\.com/i.test(url)) return 'vimeo';
+  if (/tiktok\.com/i.test(url)) return 'tiktok';
+  if (/instagram\.com/i.test(url)) return 'instagram';
+  return 'direct';
+}
+
+async function addVideo(designerId, body) {
+  const url = normalizeUrl(body.url);
+  if (!url) throw new Error('url required');
+  const provider = inferProvider(url);
+  let embedId = null;
+  if (provider === 'youtube') embedId = extractYouTubeId(url);
+  else if (provider === 'vimeo') embedId = extractVimeoId(url);
+  return one(
+    `INSERT INTO mp_designer_videos (designer_id, url, provider, title, description, embed_id, is_featured, status)
+     VALUES ($1,$2,$3,$4,$5,$6,$7,'pending')
+     RETURNING *`,
+    [
+      designerId, url, provider,
+      safeText(body.title, 200), safeText(body.description, 2000),
+      embedId,
+      body.is_featured === true || body.is_featured === 'true',
+    ]
+  );
+}
+
+async function listVideos(designerId, { onlyApproved = false } = {}) {
+  const where = ['designer_id=$1'];
+  if (onlyApproved) where.push("status='approved'");
+  return many(
+    `SELECT * FROM mp_designer_videos WHERE ${where.join(' AND ')} ORDER BY is_featured DESC, display_order ASC, created_at DESC`,
+    [designerId]
+  );
+}
+
+async function deleteVideo(designerId, videoId) {
+  return q(`DELETE FROM mp_designer_videos WHERE id=$1 AND designer_id=$2`, [videoId, designerId]);
+}
+
+async function reorderVideos(designerId, order) {
+  if (!Array.isArray(order)) return;
+  for (let i = 0; i < order.length; i++) {
+    const vid = parseInt(order[i], 10);
+    if (!vid) continue;
+    await q(`UPDATE mp_designer_videos SET display_order=$1 WHERE id=$2 AND designer_id=$3`, [i, vid, designerId]);
+  }
+}
+
+module.exports = {
+  PROFILE_UPDATABLE,
+  STEPS,
+  normalizeUrl,
+  normalizePayload,
+  updateDesignerProfile,
+  computeCompleteness,
+  extractYouTubeId,
+  extractVimeoId,
+  inferProvider,
+  addVideo,
+  listVideos,
+  deleteVideo,
+  reorderVideos,
+};
diff --git a/tests/marketplace/onboarding.test.js b/tests/marketplace/onboarding.test.js
new file mode 100644
index 0000000..e814fbc
--- /dev/null
+++ b/tests/marketplace/onboarding.test.js
@@ -0,0 +1,167 @@
+// Tests for src/marketplace/onboarding.js — pure helpers + DB lifecycle.
+// Self-contained; creates a fixture designer and tears it down.
+
+const assert = require('assert');
+const { q, one } = require('../../src/marketplace/db');
+const ob = require('../../src/marketplace/onboarding');
+
+const tests = [];
+function t(name, fn) { tests.push({ name, fn }); }
+
+// ── Pure helpers
+t('normalizeUrl prepends https:// when missing', () => {
+  assert.strictEqual(ob.normalizeUrl('yourstudio.com'), 'https://yourstudio.com');
+  assert.strictEqual(ob.normalizeUrl('http://x.com'), 'http://x.com');
+  assert.strictEqual(ob.normalizeUrl('https://x.com'), 'https://x.com');
+  assert.strictEqual(ob.normalizeUrl(''), null);
+  assert.strictEqual(ob.normalizeUrl(null), null);
+});
+
+t('extractYouTubeId handles watch/shortlink/embed', () => {
+  assert.strictEqual(ob.extractYouTubeId('https://www.youtube.com/watch?v=dQw4w9WgXcQ'), 'dQw4w9WgXcQ');
+  assert.strictEqual(ob.extractYouTubeId('https://youtu.be/dQw4w9WgXcQ'), 'dQw4w9WgXcQ');
+  assert.strictEqual(ob.extractYouTubeId('https://www.youtube.com/embed/dQw4w9WgXcQ'), 'dQw4w9WgXcQ');
+  assert.strictEqual(ob.extractYouTubeId('https://example.com'), null);
+});
+
+t('extractVimeoId parses /N from vimeo URL', () => {
+  assert.strictEqual(ob.extractVimeoId('https://vimeo.com/123456789'), '123456789');
+  assert.strictEqual(ob.extractVimeoId('https://example.com'), null);
+});
+
+t('inferProvider routes URLs correctly', () => {
+  assert.strictEqual(ob.inferProvider('https://youtube.com/watch?v=x'), 'youtube');
+  assert.strictEqual(ob.inferProvider('https://youtu.be/x'), 'youtube');
+  assert.strictEqual(ob.inferProvider('https://vimeo.com/1'), 'vimeo');
+  assert.strictEqual(ob.inferProvider('https://tiktok.com/@x'), 'tiktok');
+  assert.strictEqual(ob.inferProvider('https://instagram.com/p/x'), 'instagram');
+  assert.strictEqual(ob.inferProvider('https://example.com/x.mp4'), 'direct');
+});
+
+t('normalizePayload strips fields not on allowlist', () => {
+  const p = ob.normalizePayload({
+    studio_name: 'Foo',
+    status: 'approved',          // not allowlisted — must be ignored
+    is_founding: true,           // not allowlisted — must be ignored
+    commission_rate: 99,         // not allowlisted — must be ignored
+    website_url: 'foo.com',      // allowlisted → normalized
+  });
+  assert.strictEqual(p.studio_name, 'Foo');
+  assert.strictEqual(p.website_url, 'https://foo.com');
+  assert.ok(!('status' in p));
+  assert.ok(!('is_founding' in p));
+  assert.ok(!('commission_rate' in p));
+});
+
+t('normalizePayload coerces public_contact_consent to boolean', () => {
+  assert.strictEqual(ob.normalizePayload({ public_contact_consent: true }).public_contact_consent, true);
+  assert.strictEqual(ob.normalizePayload({ public_contact_consent: 'true' }).public_contact_consent, true);
+  assert.strictEqual(ob.normalizePayload({ public_contact_consent: false }).public_contact_consent, false);
+  assert.strictEqual(ob.normalizePayload({ public_contact_consent: 'no' }).public_contact_consent, false);
+});
+
+// ── Lifecycle
+let fixtureDesignerId = null;
+const RUN_TAG = '__onboarding_test_' + Date.now();
+
+t('create fixture designer', async () => {
+  const r = await one(
+    `INSERT INTO mp_designer_profiles (display_name, slug, status) VALUES ($1,$2,'approved') RETURNING id`,
+    ['Onboarding Test ' + Date.now(), RUN_TAG]
+  );
+  fixtureDesignerId = Number(r.id);
+});
+
+t('updateDesignerProfile persists allowlisted fields and ignores forbidden', async () => {
+  const updated = await ob.updateDesignerProfile(fixtureDesignerId, {
+    studio_name: 'My Studio',
+    website_url: 'mysite.com',
+    contact_email: 'hello@mysite.com',
+    phone: '+1 415 555 0000',
+    city: 'San Francisco',
+    state_region: 'CA',
+    country: 'USA',
+    public_contact_consent: true,
+    status: 'rejected',   // forbidden — must NOT change status
+    is_founding: true,    // forbidden — must NOT flip founding
+  });
+  assert.strictEqual(updated.studio_name, 'My Studio');
+  assert.strictEqual(updated.website_url, 'https://mysite.com');
+  assert.strictEqual(updated.contact_email, 'hello@mysite.com');
+  assert.strictEqual(updated.city, 'San Francisco');
+  assert.strictEqual(updated.public_contact_consent, true);
+  assert.strictEqual(updated.status, 'approved', 'status must not change via designer self-PATCH');
+  assert.strictEqual(updated.is_founding, false, 'is_founding must not flip via designer self-PATCH');
+});
+
+t('completeness reflects partial onboarding', async () => {
+  const c = await ob.computeCompleteness(fixtureDesignerId);
+  // profile: bio missing, avatar_url missing  → not done
+  // web:     website_url set, instagram_url missing → not done
+  // contact: contact_email set, city set, country set → DONE
+  // videos:  no videos → not done
+  // rights:  rights_agreement_at NULL → not done
+  assert.ok(c.pct >= 0 && c.pct <= 100);
+  const contactStep = c.steps.find((s) => s.key === 'contact');
+  assert.strictEqual(contactStep.done, true);
+  const videosStep = c.steps.find((s) => s.key === 'videos');
+  assert.strictEqual(videosStep.done, false);
+});
+
+let fixtureVideoId = null;
+t('addVideo parses YouTube URL → embed_id + provider=youtube', async () => {
+  const v = await ob.addVideo(fixtureDesignerId, {
+    url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
+    title: 'Test video',
+    description: 'unit test',
+    is_featured: true,
+  });
+  fixtureVideoId = Number(v.id);
+  assert.strictEqual(v.provider, 'youtube');
+  assert.strictEqual(v.embed_id, 'dQw4w9WgXcQ');
+  assert.strictEqual(v.status, 'pending');
+  assert.strictEqual(v.is_featured, true);
+});
+
+t('listVideos returns the new video (pending status)', async () => {
+  const vids = await ob.listVideos(fixtureDesignerId);
+  assert.strictEqual(vids.length, 1);
+  assert.strictEqual(Number(vids[0].id), fixtureVideoId);
+});
+
+t('listVideos onlyApproved=true filters out pending', async () => {
+  const vids = await ob.listVideos(fixtureDesignerId, { onlyApproved: true });
+  assert.strictEqual(vids.length, 0);
+});
+
+t('completeness flips videos step to done after add', async () => {
+  const c = await ob.computeCompleteness(fixtureDesignerId);
+  const videosStep = c.steps.find((s) => s.key === 'videos');
+  assert.strictEqual(videosStep.done, true);
+});
+
+t('addVideo without url throws', async () => {
+  await assert.rejects(() => ob.addVideo(fixtureDesignerId, {}), /url required/);
+});
+
+t('deleteVideo only removes designer-owned video', async () => {
+  await ob.deleteVideo(fixtureDesignerId, fixtureVideoId);
+  const vids = await ob.listVideos(fixtureDesignerId);
+  assert.strictEqual(vids.length, 0);
+});
+
+t('teardown', async () => {
+  await q(`DELETE FROM mp_designer_videos WHERE designer_id=$1`, [fixtureDesignerId]);
+  await q(`DELETE FROM mp_designer_profiles WHERE id=$1`, [fixtureDesignerId]);
+});
+
+(async () => {
+  let pass = 0, fail = 0;
+  for (const test of tests) {
+    try { await test.fn(); console.log('✓', test.name); pass++; }
+    catch (err) { console.error('✗', test.name, '—', err.message); fail++; }
+  }
+  console.log(`\n${pass}/${tests.length} onboarding tests passed`);
+  try { await require('../../src/marketplace/db').pool.end(); } catch (_) {}
+  if (fail) process.exit(1);
+})();
diff --git a/tests/marketplace/smoke.test.js b/tests/marketplace/smoke.test.js
index 2b85e5f..576ed2e 100644
--- a/tests/marketplace/smoke.test.js
+++ b/tests/marketplace/smoke.test.js
@@ -142,8 +142,81 @@ t('POST /api/marketplace/admin/challenges rejects without admin token', async ()
   assert.strictEqual(r.status, 403);
 });
 
+t('GET /api/marketplace/designers/locations returns distinct cities w/ counts', async () => {
+  const r = await req('GET', '/api/marketplace/designers/locations');
+  assert.strictEqual(r.status, 200);
+  assert.ok(Array.isArray(r.json.locations));
+  if (r.json.locations.length) {
+    const loc = r.json.locations[0];
+    assert.ok(typeof loc.location === 'string');
+    assert.ok(Number.isInteger(loc.designer_count));
+  }
+});
+
+t('GET /api/marketplace/designers?sort=location works', async () => {
+  const r = await req('GET', '/api/marketplace/designers?sort=location&limit=10');
+  assert.strictEqual(r.status, 200);
+  assert.ok(Array.isArray(r.json.designers));
+  // Each designer should have city/state fields (may be null) injected by the route.
+  if (r.json.designers.length) {
+    const d = r.json.designers[0];
+    assert.ok(Object.prototype.hasOwnProperty.call(d, 'city'));
+    assert.ok(Object.prototype.hasOwnProperty.call(d, 'state'));
+    assert.ok(Object.prototype.hasOwnProperty.call(d, 'website_url'));
+  }
+});
+
+t('GET /api/marketplace/designers?location=Brooklyn filters', async () => {
+  const r = await req('GET', '/api/marketplace/designers?location=Brooklyn');
+  assert.strictEqual(r.status, 200);
+  assert.ok(Array.isArray(r.json.designers));
+  // Either zero results or every returned row's location must include "Brooklyn".
+  r.json.designers.forEach((d) => {
+    assert.ok(/brooklyn/i.test(d.location || ''), 'unexpected location ' + d.location);
+  });
+});
+
+t('PATCH /api/marketplace/designer/me requires designer login', async () => {
+  const r = await req('PATCH', '/api/marketplace/designer/me', { studio_name: 'x' });
+  assert.strictEqual(r.status, 401);
+});
+
+t('GET /api/marketplace/designer/me/completeness requires login', async () => {
+  const r = await req('GET', '/api/marketplace/designer/me/completeness');
+  assert.strictEqual(r.status, 401);
+});
+
+t('GET /api/marketplace/designer/me/videos requires login', async () => {
+  const r = await req('GET', '/api/marketplace/designer/me/videos');
+  assert.strictEqual(r.status, 401);
+});
+
+t('POST /api/marketplace/designer/me/videos requires login', async () => {
+  const r = await req('POST', '/api/marketplace/designer/me/videos', { url: 'https://x.com' });
+  assert.strictEqual(r.status, 401);
+});
+
+t('GET /api/marketplace/designers/:slug/videos returns array', async () => {
+  const r = await req('GET', '/api/marketplace/designers/astrid-mauve/videos');
+  assert.strictEqual(r.status, 200);
+  assert.ok(Array.isArray(r.json.videos));
+});
+
+t('GET /api/marketplace/designers/:slug includes videos field + redacts contact PII by default', async () => {
+  const r = await req('GET', '/api/marketplace/designers/astrid-mauve');
+  assert.strictEqual(r.status, 200);
+  assert.ok(Array.isArray(r.json.videos));
+  // Without public_contact_consent=true, phone/contact_email/address should be null.
+  const d = r.json.designer;
+  if (d && d.public_contact_consent !== true) {
+    assert.strictEqual(d.phone, null, 'phone leaked without consent');
+    assert.strictEqual(d.contact_email, null, 'contact_email leaked without consent');
+    assert.strictEqual(d.address_line1, null, 'address leaked without consent');
+  }
+});
+
 t('Page routes serve HTML (200)', async () => {
-  for (const path of ['/marketplace','/designers','/patterns','/marketplace/status','/marketplace/apply','/marketplace/dashboard','/marketplace/admin','/marketplace/claim','/marketplace/leaderboard','/marketplace/challenges','/designers/astrid-mauve','/store/astrid-mauve']) {
+  for (const path of ['/marketplace','/designers','/patterns','/marketplace/status','/marketplace/apply','/marketplace/dashboard','/marketplace/admin','/marketplace/claim','/marketplace/leaderboard','/marketplace/challenges','/marketplace/onboard','/designers/astrid-mauve','/store/astrid-mauve']) {
     const r = await req('GET', path);
     assert.strictEqual(r.status, 200, path + ' → ' + r.status);
   }

← 912870e marketplace db: warn + fall through on non-URL DATABASE_URL;  ·  back to Wallco Ai  ·  marketplace ai tests: 5×8s retry budget to ride out parallel 53a5430 →