← 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
M public/css/site.cssM public/marketplace/_layout.cssM public/marketplace/_partials.jsM public/marketplace/designer-profile.htmlM public/marketplace/designers.htmlM public/marketplace/leaderboard.htmlA public/marketplace/onboard.htmlA sql/102_marketplace_onboarding.sqlM src/marketplace/challenges.jsM src/marketplace/index.jsA src/marketplace/onboarding.jsA tests/marketplace/onboarding.test.jsM tests/marketplace/smoke.test.js
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
+function escapeAttr(s) { return String(s == null ? '' : s).replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<'); }
+
+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,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<');
+}
+
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 →