← back to Wallco Ai
marketplace: designer-powered Wallco marketplace (Patternbank × Material Bank × Spoonflower)
27158ceaaa32679ceafaea7a7d0d317c131bc35c · 2026-05-13 14:45:04 -0700 · SteveStudio2
Adds a complete additive marketplace layer to wallco-ai:
- 19 mp_* tables (designer profiles, claim pages, patterns, colorways,
collections, points ledger, commission ledger, payout batches, licensing
inquiries, project saves, followers, moderation events, takedowns)
- src/marketplace/ module mounted with one line in server.js
- 4 AI stub endpoints (colorways, tag, description, room mockup) — ready to
wire to Gemini/Ollama qwen3:14b
- Commission engine on net product revenue (line − discount − refund −
sample-credit; never tax/shipping); 6 ledger statuses pending→paid
- Gamification w/ points ledger, 7 levels New Creator → Hall of Pattern,
18 badges (Founding, Verified, Color Genius, Hospitality Ready, etc.)
- Pages: /marketplace, /designers, /designers/:slug, /store/:slug,
/patterns, /patterns/:slug, /licensing/:slug, /marketplace/apply,
/marketplace/dashboard, /marketplace/admin, /marketplace/claim,
/marketplace/status (live "what's happening" board polling every 4s)
- Claim-page system w/ legally-safe disclaimer; admin approval before
any public storefront publishes
- Seed: 5 fictional founding designers, 24 patterns sourced from
Wallco-owned spoon_all_designs, 8 collections, 6 commission entries
($545.60 pending)
- 29/29 tests passing (8 commission unit + 11 gamification unit + 10 HTTP smoke)
Files touched
M package-lock.jsonM package.jsonA public/marketplace/_layout.cssA public/marketplace/_partials.jsA public/marketplace/admin.htmlA public/marketplace/apply.htmlA public/marketplace/claim.htmlA public/marketplace/dashboard.htmlA public/marketplace/designer-profile.htmlA public/marketplace/designers.htmlA public/marketplace/index.htmlA public/marketplace/licensing.htmlA public/marketplace/pattern.htmlA public/marketplace/patterns.htmlA public/marketplace/status.htmlA public/marketplace/storefront.htmlA scripts/seed-marketplace.jsM server.jsA sql/100_designer_marketplace.sqlA src/marketplace/ai.jsA src/marketplace/commissions.jsA src/marketplace/db.jsA src/marketplace/gamification.jsA src/marketplace/index.jsA src/marketplace/util.jsA tests/marketplace/commissions.test.jsA tests/marketplace/gamification.test.jsA tests/marketplace/smoke.test.js
Diff
commit 27158ceaaa32679ceafaea7a7d0d317c131bc35c
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Wed May 13 14:45:04 2026 -0700
marketplace: designer-powered Wallco marketplace (Patternbank × Material Bank × Spoonflower)
Adds a complete additive marketplace layer to wallco-ai:
- 19 mp_* tables (designer profiles, claim pages, patterns, colorways,
collections, points ledger, commission ledger, payout batches, licensing
inquiries, project saves, followers, moderation events, takedowns)
- src/marketplace/ module mounted with one line in server.js
- 4 AI stub endpoints (colorways, tag, description, room mockup) — ready to
wire to Gemini/Ollama qwen3:14b
- Commission engine on net product revenue (line − discount − refund −
sample-credit; never tax/shipping); 6 ledger statuses pending→paid
- Gamification w/ points ledger, 7 levels New Creator → Hall of Pattern,
18 badges (Founding, Verified, Color Genius, Hospitality Ready, etc.)
- Pages: /marketplace, /designers, /designers/:slug, /store/:slug,
/patterns, /patterns/:slug, /licensing/:slug, /marketplace/apply,
/marketplace/dashboard, /marketplace/admin, /marketplace/claim,
/marketplace/status (live "what's happening" board polling every 4s)
- Claim-page system w/ legally-safe disclaimer; admin approval before
any public storefront publishes
- Seed: 5 fictional founding designers, 24 patterns sourced from
Wallco-owned spoon_all_designs, 8 collections, 6 commission entries
($545.60 pending)
- 29/29 tests passing (8 commission unit + 11 gamification unit + 10 HTTP smoke)
---
package-lock.json | 149 ++++++++-
package.json | 3 +-
public/marketplace/_layout.css | 139 +++++++++
public/marketplace/_partials.js | 50 +++
public/marketplace/admin.html | 169 ++++++++++
public/marketplace/apply.html | 99 ++++++
public/marketplace/claim.html | 59 ++++
public/marketplace/dashboard.html | 222 +++++++++++++
public/marketplace/designer-profile.html | 96 ++++++
public/marketplace/designers.html | 77 +++++
public/marketplace/index.html | 124 ++++++++
public/marketplace/licensing.html | 79 +++++
public/marketplace/pattern.html | 86 +++++
public/marketplace/patterns.html | 81 +++++
public/marketplace/status.html | 199 ++++++++++++
public/marketplace/storefront.html | 90 ++++++
scripts/seed-marketplace.js | 277 ++++++++++++++++
server.js | 3 +
sql/100_designer_marketplace.sql | 348 +++++++++++++++++++++
src/marketplace/ai.js | 57 ++++
src/marketplace/commissions.js | 60 ++++
src/marketplace/db.js | 58 ++++
src/marketplace/gamification.js | 116 +++++++
src/marketplace/index.js | 520 +++++++++++++++++++++++++++++++
src/marketplace/util.js | 34 ++
tests/marketplace/commissions.test.js | 70 +++++
tests/marketplace/gamification.test.js | 48 +++
tests/marketplace/smoke.test.js | 102 ++++++
28 files changed, 3413 insertions(+), 2 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index cae9b8f..f1a06cf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,7 +15,8 @@
"express": "^5.2.1",
"express-rate-limit": "^8.5.1",
"jsonwebtoken": "^9.0.3",
- "multer": "^2.1.1"
+ "multer": "^2.1.1",
+ "pg": "^8.20.0"
}
},
"../inline-editor": {
@@ -894,6 +895,134 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/pg": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
+ "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.12.0",
+ "pg-pool": "^3.13.0",
+ "pg-protocol": "^1.13.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.3.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
+ "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.12.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
+ "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.13.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
+ "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
+ "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1137,6 +1266,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1221,6 +1359,15 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
}
}
}
diff --git a/package.json b/package.json
index 86cee58..cee773d 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"express": "^5.2.1",
"express-rate-limit": "^8.5.1",
"jsonwebtoken": "^9.0.3",
- "multer": "^2.1.1"
+ "multer": "^2.1.1",
+ "pg": "^8.20.0"
}
}
diff --git a/public/marketplace/_layout.css b/public/marketplace/_layout.css
new file mode 100644
index 0000000..7878cec
--- /dev/null
+++ b/public/marketplace/_layout.css
@@ -0,0 +1,139 @@
+/* Wallco Marketplace shared style — Apple-glass aesthetic, neutral, designer-celebratory */
+:root {
+ --mp-bg: #faf8f4;
+ --mp-fg: #111;
+ --mp-muted: #6b6b6b;
+ --mp-border: rgba(20,20,20,.08);
+ --mp-card: rgba(255,255,255,.78);
+ --mp-accent: #111;
+ --mp-accent-bg: #111;
+ --mp-accent-fg: #fff;
+ --mp-glass-shadow: 0 6px 30px rgba(20,20,20,.06), 0 1px 0 rgba(255,255,255,.6) inset;
+ --mp-pad: clamp(12px, 2vw, 28px);
+ --mp-radius: 20px;
+}
+* { 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; }
+a { color: inherit; text-decoration: none; }
+a.under { text-decoration: underline; text-underline-offset: 4px; }
+
+.mp-nav {
+ position: sticky; top: 0; z-index: 30;
+ 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);
+ border-bottom: 1px solid var(--mp-border);
+}
+.mp-nav .brand { font-weight: 700; letter-spacing: .02em; font-size: 17px; }
+.mp-nav .links { display: flex; gap: 18px; font-size: 14px; color: var(--mp-muted); }
+.mp-nav .links a:hover { color: #000; }
+.mp-nav .menu-btn { display: none; }
+
+.mp-hero {
+ padding: clamp(48px, 7vw, 120px) var(--mp-pad);
+ display: grid; gap: 18px; max-width: 1280px; margin: 0 auto;
+}
+.mp-hero h1 { font-size: clamp(36px, 5vw, 72px); margin: 0; letter-spacing: -.02em; line-height: 1.02; }
+.mp-hero p.lede { font-size: clamp(16px, 1.4vw, 20px); max-width: 720px; color: var(--mp-muted); line-height: 1.55; }
+.mp-hero .cta-row { display:flex; gap:12px; flex-wrap:wrap; margin-top: 8px; }
+
+.mp-btn {
+ display: inline-flex; align-items: center; gap: 8px;
+ background: var(--mp-accent-bg); color: var(--mp-accent-fg);
+ padding: 12px 22px; border-radius: 999px; font-weight: 600; font-size: 14px;
+ border: 0; cursor: pointer; transition: transform .15s ease, opacity .15s ease;
+}
+.mp-btn:hover { transform: translateY(-1px); }
+.mp-btn.ghost { background: transparent; color: var(--mp-fg); border: 1px solid var(--mp-border); }
+.mp-btn.tiny { padding: 6px 12px; font-size: 12px; }
+
+.mp-wrap { max-width: 1280px; margin: 0 auto; padding: var(--mp-pad); }
+
+.mp-grid { display: grid; gap: 18px; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
+.mp-grid.lg { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
+.mp-grid.xl { grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); }
+
+.mp-card {
+ background: var(--mp-card);
+ border: 1px solid var(--mp-border);
+ border-radius: var(--mp-radius);
+ box-shadow: var(--mp-glass-shadow);
+ overflow: hidden;
+ backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
+ transition: transform .2s ease, box-shadow .2s ease;
+}
+.mp-card:hover { transform: translateY(-3px); box-shadow: 0 12px 40px rgba(20,20,20,.1); }
+.mp-card .img {
+ aspect-ratio: 1/1; background: #eee; background-size: cover; background-position: center;
+}
+.mp-card .body { padding: 14px 16px; display:flex; flex-direction:column; gap: 6px; }
+.mp-card .title { font-weight: 600; font-size: 15px; }
+.mp-card .sub { font-size: 13px; color: var(--mp-muted); }
+.mp-card .row { display:flex; align-items:center; justify-content:space-between; gap: 8px; margin-top: 6px; }
+
+.mp-pill { display: inline-block; padding: 4px 10px; border-radius: 999px; background: rgba(20,20,20,.06); font-size: 11px; color: #2a2a2a; }
+.mp-pill.accent { background: #111; color: #fff; }
+.mp-pill.gold { background: linear-gradient(135deg,#cda86e,#f2dca5); color: #2c2110; }
+.mp-pill.verified { background: #0a7; color: #fff; }
+
+.mp-section { padding: clamp(28px, 4vw, 56px) 0; border-top: 1px solid var(--mp-border); }
+.mp-section h2 { font-size: clamp(22px, 2.4vw, 32px); margin: 0 0 18px; letter-spacing: -.01em; }
+.mp-section .sub { color: var(--mp-muted); margin-bottom: 18px; }
+
+.mp-stat { display:flex; flex-direction: column; gap: 4px; padding: 18px 20px; background: var(--mp-card); border: 1px solid var(--mp-border); border-radius: 16px; box-shadow: var(--mp-glass-shadow); }
+.mp-stat .label { font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--mp-muted); }
+.mp-stat .value { font-size: 28px; font-weight: 700; letter-spacing: -.01em; }
+.mp-stat.dim .value { color: var(--mp-muted); font-weight: 600; }
+
+.mp-form { display:flex; flex-direction:column; gap: 14px; max-width: 560px; }
+.mp-form label { display:flex; flex-direction:column; gap: 6px; font-size: 13px; color: #333; }
+.mp-form input[type=text], .mp-form input[type=email], .mp-form input[type=url], .mp-form input[type=number], .mp-form textarea, .mp-form select {
+ border: 1px solid var(--mp-border); padding: 10px 12px; border-radius: 12px; font-size: 14px; font-family: inherit; background: #fff;
+}
+.mp-form textarea { min-height: 100px; resize: vertical; }
+.mp-form .row { display: flex; gap: 10px; }
+.mp-form .row label { flex: 1; }
+.mp-form .check { flex-direction: row; align-items: center; gap: 8px; font-size: 13px; }
+.mp-form .err { color: #c33; font-size: 13px; }
+.mp-form .ok { color: #060; font-size: 13px; }
+
+.mp-claim-banner {
+ background: linear-gradient(135deg, rgba(255,255,255,.92), rgba(255,255,255,.78));
+ border: 1px solid var(--mp-border); border-radius: 22px; padding: 22px 24px; margin: 22px 0;
+ box-shadow: var(--mp-glass-shadow); backdrop-filter: blur(14px);
+}
+.mp-claim-banner h3 { margin: 0 0 6px; font-size: 17px; }
+.mp-claim-banner .small { color: var(--mp-muted); font-size: 13px; margin-bottom: 14px; line-height: 1.5; }
+
+.mp-footer { padding: 40px var(--mp-pad); text-align: center; color: var(--mp-muted); font-size: 13px; border-top: 1px solid var(--mp-border); margin-top: 60px; }
+
+/* Hamburger (Gucci-style ≡ MENU) */
+.mp-hamburger { display:none; }
+@media (max-width: 720px) {
+ .mp-nav .links { display:none; }
+ .mp-nav .menu-btn { display: inline-flex; align-items:center; gap:6px; background: transparent; border: 0; cursor: pointer; font-size: 12px; letter-spacing: .12em; text-transform: uppercase; }
+ .mp-nav .menu-btn .bars { display: inline-flex; flex-direction: column; gap: 3px; }
+ .mp-nav .menu-btn .bars span { display:block; width: 18px; height: 1px; background: #111; }
+}
+
+/* Designer Profile / Storefront hero */
+.mp-storefront-hero {
+ padding: clamp(40px, 6vw, 90px) var(--mp-pad);
+ display: grid; gap: 16px; grid-template-columns: minmax(180px, 220px) 1fr;
+ align-items: center; max-width: 1280px; margin: 0 auto;
+}
+.mp-storefront-hero .avatar { width: 220px; height: 220px; border-radius: 22px; background: #ddd; background-size: cover; background-position: center; box-shadow: var(--mp-glass-shadow); }
+.mp-storefront-hero h1 { margin: 0; font-size: clamp(34px, 4vw, 56px); letter-spacing: -.01em; }
+.mp-storefront-hero .studio { color: var(--mp-muted); font-size: 16px; }
+.mp-storefront-hero .badges { display: flex; gap: 6px; flex-wrap: wrap; margin: 10px 0; }
+.mp-storefront-hero .meta { display: flex; gap: 14px; color: var(--mp-muted); font-size: 13px; }
+
+/* Status board */
+.mp-status-grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
+.mp-status-board { padding: 24px; background: var(--mp-card); border: 1px solid var(--mp-border); border-radius: 24px; box-shadow: var(--mp-glass-shadow); }
+
+@media (max-width: 720px) {
+ .mp-storefront-hero { grid-template-columns: 1fr; }
+ .mp-storefront-hero .avatar { width: 140px; height: 140px; }
+}
diff --git a/public/marketplace/_partials.js b/public/marketplace/_partials.js
new file mode 100644
index 0000000..18d0d31
--- /dev/null
+++ b/public/marketplace/_partials.js
@@ -0,0 +1,50 @@
+// Shared header/footer + tiny helpers. Loaded as a plain script.
+(function(){
+ function el(tag, attrs={}, children=[]){ const e=document.createElement(tag); for(const k in attrs){ if(k==='style') Object.assign(e.style, attrs[k]); else if(k==='dataset') Object.assign(e.dataset, attrs[k]); else if(k==='html') e.innerHTML = attrs[k]; else e.setAttribute(k, attrs[k]); } (children||[]).forEach(c=> e.appendChild(typeof c==='string'? document.createTextNode(c): c)); return e; }
+
+ function buildNav() {
+ const links = [
+ ['/marketplace', 'Marketplace'],
+ ['/designers', 'Designers'],
+ ['/patterns', 'Patterns'],
+ ['/marketplace/apply', 'Become a Designer'],
+ ['/marketplace/dashboard', 'Dashboard'],
+ ['/marketplace/status', 'Status'],
+ ];
+ const nav = el('nav', { class: 'mp-nav' }, [
+ el('a', { class: 'brand', href: '/marketplace' }, ['Wallco · Marketplace']),
+ el('div', { class: 'links' }, links.map(([href,t]) => el('a', { href }, [t]))),
+ el('button', { class: 'menu-btn', 'aria-label': 'Menu' }, [
+ el('span', { class: 'bars' }, [ el('span'), el('span'), el('span') ]),
+ document.createTextNode(' MENU')
+ ])
+ ]);
+ document.body.prepend(nav);
+ const mb = nav.querySelector('.menu-btn');
+ if (mb) mb.addEventListener('click', () => {
+ const links = nav.querySelector('.links');
+ links.style.display = (links.style.display === 'flex') ? '' : 'flex';
+ links.style.flexDirection = 'column';
+ links.style.position = 'absolute';
+ links.style.right = '12px';
+ links.style.top = '52px';
+ links.style.background = '#fff';
+ links.style.padding = '10px 14px';
+ links.style.borderRadius = '12px';
+ links.style.boxShadow = '0 12px 40px rgba(0,0,0,.1)';
+ });
+ }
+ function buildFooter() {
+ const f = el('footer', { class: 'mp-footer' }, [
+ el('div', {}, ['Wallco · Marketplace — designer-led wallcoverings, AI colorways, samples, licensing.']),
+ el('div', { style: { marginTop: '6px' } }, ['Built on wallco.ai — every pattern attributable, every commission auditable.']),
+ ]);
+ document.body.appendChild(f);
+ }
+ function fmt$(n){ return '$' + Number(n||0).toLocaleString(undefined,{minimumFractionDigits:0,maximumFractionDigits:2}); }
+ function fmtN(n){ return Number(n||0).toLocaleString(); }
+ async function api(path, opts={}){ const r = await fetch(path, Object.assign({ credentials:'include', headers:{'content-type':'application/json'} }, opts)); if(!r.ok && opts.throwOnError !== false){ let err = ''; try{ err = (await r.json()).error || r.statusText; } catch(_){ err = r.statusText; } throw new Error(err); } return r.json(); }
+
+ window.MP = { el, buildNav, buildFooter, fmt$, fmtN, api };
+ document.addEventListener('DOMContentLoaded', () => { buildNav(); buildFooter(); });
+})();
diff --git a/public/marketplace/admin.html b/public/marketplace/admin.html
new file mode 100644
index 0000000..43573ce
--- /dev/null
+++ b/public/marketplace/admin.html
@@ -0,0 +1,169 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<title>Marketplace Admin · Wallco</title>
+<meta name="viewport" content="width=device-width,initial-scale=1" />
+<link rel="stylesheet" href="/marketplace/_layout.css" />
+<script src="/marketplace/_partials.js" defer></script>
+<style>
+ body { background: #f6f4ee; }
+ .admin-grid { display: grid; gap: 22px; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); }
+ .panel { padding: 18px; background: var(--mp-card); border:1px solid var(--mp-border); border-radius: 18px; box-shadow: var(--mp-glass-shadow); }
+ .panel h3 { margin-top: 0; }
+ .panel table { width: 100%; border-collapse: collapse; font-size: 13px; }
+ .panel th, .panel td { padding: 7px 8px; text-align: left; border-bottom: 1px solid var(--mp-border); }
+ .admin-token { padding: 6px 10px; border-radius: 999px; font-size: 12px; background: #111; color: #fff; }
+ .row-act { display: flex; gap: 6px; }
+ .mp-btn.danger { background: #c33; }
+ .mp-btn.good { background: #0a7; }
+</style>
+</head>
+<body>
+ <header class="mp-hero">
+ <h1>Marketplace admin</h1>
+ <p class="lede">Approve designers, moderate patterns, review claim requests, payout exports. All actions audited to <code>mp_moderation_events</code>.</p>
+ <div>
+ <input id="adm" placeholder="ADMIN_TOKEN" style="padding:8px 12px;border-radius:999px;border:1px solid var(--mp-border);font-size:13px;width:280px"/>
+ <button class="mp-btn tiny" onclick="setTok()">Save token</button>
+ <span id="tokOk" class="sub" style="margin-left:8px"></span>
+ </div>
+ </header>
+ <main class="mp-wrap">
+ <div class="admin-grid">
+
+ <section class="panel">
+ <h3>Pending designers</h3>
+ <table id="dTable"><thead><tr><th>Name</th><th>Slug</th><th>Status</th><th></th></tr></thead><tbody></tbody></table>
+ </section>
+
+ <section class="panel">
+ <h3>Pending patterns</h3>
+ <table id="pTable"><thead><tr><th>Title</th><th>Designer</th><th>Source</th><th></th></tr></thead><tbody></tbody></table>
+ </section>
+
+ <section class="panel">
+ <h3>Claim requests</h3>
+ <table id="cTable"><thead><tr><th>Name</th><th>Slug</th><th>Status</th><th>Created</th></tr></thead><tbody></tbody></table>
+ </section>
+
+ <section class="panel">
+ <h3>Licensing inquiries</h3>
+ <table id="lTable"><thead><tr><th>Pattern</th><th>Buyer</th><th>Type</th><th>Status</th></tr></thead><tbody></tbody></table>
+ </section>
+
+ <section class="panel">
+ <h3>Commission payable</h3>
+ <p class="sub">Entries currently payable; click to mark paid.</p>
+ <table id="payTable"><thead><tr><th>When</th><th>Designer</th><th>Type</th><th>Amount</th><th></th></tr></thead><tbody></tbody></table>
+ <a class="mp-btn ghost tiny" href="#" onclick="exportPayouts(event)">Export CSV</a>
+ </section>
+
+ </div>
+ </main>
+
+<script>
+const TOK_KEY = 'wallco.mp.admin_token';
+let TOK = localStorage.getItem(TOK_KEY) || '';
+document.getElementById('adm').value = TOK;
+document.getElementById('tokOk').textContent = TOK ? '✓ token loaded' : '';
+
+function setTok() {
+ TOK = document.getElementById('adm').value.trim();
+ localStorage.setItem(TOK_KEY, TOK);
+ document.getElementById('tokOk').textContent = '✓ saved';
+ loadAll();
+}
+
+function hdr() { return TOK ? { 'x-admin-token': TOK } : {}; }
+async function api(p, opts={}) {
+ opts.headers = Object.assign({}, opts.headers || {}, hdr(), {'content-type':'application/json'});
+ opts.credentials = 'include';
+ const r = await fetch(p, opts);
+ const j = await r.json().catch(() => ({}));
+ if (!r.ok) throw new Error(j.error || r.statusText);
+ return j;
+}
+
+async function loadDesigners() {
+ // we need a designers-pending endpoint; fall back to /api/marketplace/designers all
+ try {
+ const r = await fetch('/api/marketplace/designers?limit=500&sort=newest');
+ const j = await r.json();
+ const tb = document.getElementById('dTable').querySelector('tbody'); tb.innerHTML='';
+ (j.designers || []).forEach(d => {
+ const tr = document.createElement('tr');
+ tr.innerHTML = `<td>${d.display_name}</td><td><a class="under" href="/designers/${d.slug}" target="_blank">${d.slug}</a></td><td><span class="mp-pill">${d.level}</span></td>
+ <td class="row-act">
+ <button class="mp-btn tiny good" onclick="approveDesigner(${d.id})">Approve</button>
+ <button class="mp-btn tiny" onclick="verifyDesigner(${d.id})">Verify</button>
+ <button class="mp-btn tiny" onclick="foundingDesigner(${d.id})">Founding</button>
+ </td>`;
+ tb.appendChild(tr);
+ });
+ } catch (err) { console.error(err); }
+}
+
+async function loadPatterns() {
+ // public endpoint lists approved only; fetch full status by direct admin query (build new endpoint? for now just show all)
+ try {
+ const r = await fetch('/api/marketplace/patterns?limit=200');
+ const j = await r.json();
+ const tb = document.getElementById('pTable').querySelector('tbody'); tb.innerHTML='';
+ (j.patterns || []).forEach(p => {
+ const tr = document.createElement('tr');
+ tr.innerHTML = `<td>${p.title}</td><td>${p.designer_name}</td><td>internal</td>
+ <td class="row-act">
+ <button class="mp-btn tiny good" onclick="approvePattern(${p.id})">Approve</button>
+ <button class="mp-btn tiny danger" onclick="rejectPattern(${p.id})">Reject</button>
+ <a class="mp-btn tiny ghost" href="/patterns/${p.slug}" target="_blank">View</a>
+ </td>`;
+ tb.appendChild(tr);
+ });
+ } catch (err) { console.error(err); }
+}
+
+async function loadClaims() {
+ try {
+ const j = await api('/api/marketplace/admin/claims');
+ const tb = document.getElementById('cTable').querySelector('tbody'); tb.innerHTML='';
+ (j.claims || []).forEach(c => {
+ const tr = document.createElement('tr');
+ tr.innerHTML = `<td>${c.public_name}</td><td>${c.slug}</td><td><span class="mp-pill">${c.outreach_status}</span></td><td>${new Date(c.created_at).toLocaleDateString()}</td>`;
+ tb.appendChild(tr);
+ });
+ } catch (err) { document.getElementById('cTable').querySelector('tbody').innerHTML = `<tr><td colspan="4" class="sub">${err.message} — set ADMIN_TOKEN above.</td></tr>`; }
+}
+
+async function loadInquiries() {
+ // No public endpoint yet; show "(coming soon)"
+ const tb = document.getElementById('lTable').querySelector('tbody');
+ tb.innerHTML = `<tr><td colspan="4" class="sub">Add /api/marketplace/admin/inquiries to surface here.</td></tr>`;
+}
+
+async function loadPayables() {
+ try {
+ const j = await api('/api/marketplace/commissions/ledger');
+ const tb = document.getElementById('payTable').querySelector('tbody'); tb.innerHTML='';
+ (j.ledger || []).filter(c => ['pending','approved','payable'].includes(c.status)).forEach(c => {
+ const tr = document.createElement('tr');
+ tr.innerHTML = `<td>${new Date(c.created_at).toLocaleDateString()}</td><td>#${c.designer_id}</td><td>${c.entry_type}</td><td>$${Number(c.commission_amount).toFixed(2)}</td>
+ <td><span class="mp-pill">${c.status}</span></td>`;
+ tb.appendChild(tr);
+ });
+ } catch (err) { document.getElementById('payTable').querySelector('tbody').innerHTML = `<tr><td colspan="5" class="sub">${err.message} — set ADMIN_TOKEN above.</td></tr>`; }
+}
+
+async function approveDesigner(id) { await api(`/api/marketplace/designers/${id}/approve`, {method:'POST'}); loadDesigners(); }
+async function verifyDesigner(id) { await api(`/api/marketplace/designers/${id}/verify`, {method:'POST'}); loadDesigners(); }
+async function foundingDesigner(id){ await api(`/api/marketplace/designers/${id}/founding`,{method:'POST'}); loadDesigners(); }
+async function approvePattern(id) { await api(`/api/marketplace/patterns/${id}/approve`, {method:'POST'}); loadPatterns(); }
+async function rejectPattern(id) { const reason = prompt('Reason:'); if (!reason) return; await api(`/api/marketplace/patterns/${id}/reject`, {method:'POST', body: JSON.stringify({reason})}); loadPatterns(); }
+
+function exportPayouts(e){ e.preventDefault(); alert('Phase-2: emit CSV with designer_id,email,payout_method,total,memo from mp_commission_ledger payable rows.'); }
+
+function loadAll(){ loadDesigners(); loadPatterns(); loadClaims(); loadInquiries(); loadPayables(); }
+loadAll();
+</script>
+</body>
+</html>
diff --git a/public/marketplace/apply.html b/public/marketplace/apply.html
new file mode 100644
index 0000000..e5f4256
--- /dev/null
+++ b/public/marketplace/apply.html
@@ -0,0 +1,99 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<title>Become a Wallco Designer</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>
+</head>
+<body>
+ <header class="mp-hero">
+ <h1>Become a Wallco Designer.</h1>
+ <p class="lede">Apply to publish patterns, get AI colorways and room mockups, build your storefront, and earn commission from wallpaper sales, samples, and licensing. Founding designers get 20% royalty on wallpaper and 60–70% on licensing.</p>
+ </header>
+
+ <main class="mp-wrap">
+ <div class="mp-grid lg" style="grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);">
+ <section class="mp-card" style="padding: 22px;">
+ <h2 style="margin-top:0">Apply</h2>
+ <form id="form" class="mp-form">
+ <label>Designer / studio name *
+ <input type="text" name="display_name" required maxlength="80" placeholder="Your designer name (will appear on your public profile)" />
+ </label>
+ <label>Email *
+ <input type="email" name="email" required maxlength="200" placeholder="hi@yourstudio.com" />
+ </label>
+ <div class="row">
+ <label>Studio name <input type="text" name="studio_name" maxlength="100" placeholder="Optional" /></label>
+ <label>Location <input type="text" name="location" maxlength="150" placeholder="City, Country" /></label>
+ </div>
+ <label>Bio
+ <textarea name="bio" maxlength="2000" placeholder="Two short paragraphs about your work — style, training, what you’re known for."></textarea>
+ </label>
+ <div class="row">
+ <label>Website <input type="url" name="website_url" placeholder="https://yourstudio.com" /></label>
+ <label>Instagram <input type="url" name="instagram_url" placeholder="https://instagram.com/your-handle" /></label>
+ </div>
+ <label>Style tags (comma separated)
+ <input type="text" name="style_tags" placeholder="Botanical, Maximalist, Hospitality" />
+ </label>
+
+ <div style="border-top: 1px solid var(--mp-border); padding-top: 14px; margin-top: 6px;">
+ <label class="check">
+ <input type="checkbox" name="rights_agreement" required />
+ I confirm I own or control all rights to the patterns I will upload to Wallco, and I agree to the Wallco Designer Agreement (commission, licensing, royalty terms).
+ </label>
+ </div>
+
+ <button type="submit" class="mp-btn">Submit application</button>
+ <div id="msg" class="err" style="display:none"></div>
+ </form>
+ </section>
+
+ <aside>
+ <h3 style="margin-top:0">Founding designer benefits</h3>
+ <ul style="color: var(--mp-muted); line-height: 1.6; padding-left: 18px;">
+ <li>Free public storefront at <code>wallco.ai/store/your-slug</code></li>
+ <li>Founding Designer badge</li>
+ <li>20% royalty on eligible wallpaper sales</li>
+ <li>60% share of non-exclusive license revenue</li>
+ <li>70% share of exclusive license revenue</li>
+ <li>Free AI colorway generation for first 10 patterns</li>
+ <li>Featured launch placement</li>
+ <li>Early trade-buyer exposure</li>
+ </ul>
+ <h3>Commission rules</h3>
+ <p style="color: var(--mp-muted); line-height: 1.6; font-size: 14px;">Commission is calculated on <strong>net product revenue</strong> — line item minus discounts, refunds, and sample credits. Tax and shipping are never included. Status moves from pending → approved → payable → paid, with a 30-day return window before payout.</p>
+ <h3>Claim page disclaimer</h3>
+ <p style="color: var(--mp-muted); line-height: 1.6; font-size: 14px;">Wallco may publish an unclaimed public stub for known pattern designers. Those pages explicitly state they are <em>not affiliated with or endorsed by</em> the designer, and contain no copied artwork or bios. If you’re the designer, you can claim that page to convert it into your official storefront.</p>
+ </aside>
+ </div>
+ </main>
+
+<script>
+document.getElementById('form').addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const msg = document.getElementById('msg');
+ msg.style.display='none'; msg.className='err';
+ const f = new FormData(e.target);
+ const body = Object.fromEntries(f.entries());
+ body.rights_agreement = body.rights_agreement === 'on';
+ try {
+ const r = await fetch('/api/marketplace/designer/apply', {
+ method: 'POST', credentials: 'include',
+ headers: {'content-type':'application/json'}, body: JSON.stringify(body)
+ });
+ const j = await r.json();
+ if (!r.ok) throw new Error(j.error || 'failed');
+ msg.className = 'ok';
+ msg.style.display = 'block';
+ msg.textContent = 'Application received. Redirecting to your dashboard…';
+ setTimeout(() => { location.href = '/marketplace/dashboard'; }, 1200);
+ } catch (err) {
+ msg.style.display='block'; msg.className='err'; msg.textContent = err.message;
+ }
+});
+</script>
+</body>
+</html>
diff --git a/public/marketplace/claim.html b/public/marketplace/claim.html
new file mode 100644
index 0000000..027edfb
--- /dev/null
+++ b/public/marketplace/claim.html
@@ -0,0 +1,59 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<title>Claim your designer page · Wallco</title>
+<meta name="viewport" content="width=device-width,initial-scale=1" />
+<link rel="stylesheet" href="/marketplace/_layout.css" />
+<script src="/marketplace/_partials.js" defer></script>
+</head>
+<body>
+ <header class="mp-hero">
+ <h1>Claim your designer page.</h1>
+ <p class="lede">Wallco verifies every claim before publishing. If a public stub already exists for you, claiming it converts it into your official storefront, portfolio, and commission account.</p>
+ </header>
+ <main class="mp-wrap">
+ <div class="mp-claim-banner">
+ <h3>Disclaimer</h3>
+ <p class="small">Any unclaimed public designer profile on Wallco is <strong>not affiliated with or endorsed by the designer</strong>. Those pages contain no copied artwork, no copied bios, and no listings from other platforms. Claiming a page is how a designer adds their official portfolio, storefront, and commission settings.</p>
+ </div>
+
+ <section class="mp-card" style="padding: 22px;">
+ <h2 style="margin-top:0">Claim form</h2>
+ <form id="claim" class="mp-form">
+ <label>Your name *<input type="text" name="name" required maxlength="100"/></label>
+ <label>Email *<input type="email" name="email" required maxlength="200"/></label>
+ <label>Unclaimed page slug (if known) <input type="text" name="slug" placeholder="e.g. pattern-studio-one"/></label>
+ <div class="row">
+ <label>Website <input type="url" name="website_url"/></label>
+ <label>Instagram <input type="url" name="instagram_url"/></label>
+ </div>
+ <label>Portfolio URL <input type="url" name="portfolio_url"/></label>
+ <label>Proof / context (a sentence or two so we can verify it's you)
+ <textarea name="proof" maxlength="2000"></textarea>
+ </label>
+ <button type="submit" class="mp-btn">Submit claim</button>
+ <div id="msg" class="err" style="display:none"></div>
+ </form>
+ </section>
+ </main>
+
+<script>
+document.getElementById('claim').addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const msg = document.getElementById('msg'); msg.style.display='none';
+ const body = Object.fromEntries(new FormData(e.target).entries());
+ try {
+ const r = await fetch('/api/marketplace/designer/claim', { method:'POST', credentials:'include', headers:{'content-type':'application/json'}, body: JSON.stringify(body) });
+ const j = await r.json();
+ if(!r.ok) throw new Error(j.error || 'failed');
+ msg.className='ok'; msg.style.display='block';
+ msg.textContent = 'Claim submitted. Wallco will verify and reach out — typically within 48 hours.';
+ e.target.reset();
+ } catch (err) {
+ msg.className='err'; msg.style.display='block'; msg.textContent = err.message;
+ }
+});
+</script>
+</body>
+</html>
diff --git a/public/marketplace/dashboard.html b/public/marketplace/dashboard.html
new file mode 100644
index 0000000..8f2a45d
--- /dev/null
+++ b/public/marketplace/dashboard.html
@@ -0,0 +1,222 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<title>Designer Dashboard · Wallco</title>
+<meta name="viewport" content="width=device-width,initial-scale=1" />
+<link rel="stylesheet" href="/marketplace/_layout.css" />
+<script src="/marketplace/_partials.js" defer></script>
+<style>
+ .tabs { display:flex; gap: 8px; margin: 18px 0; flex-wrap: wrap; }
+ .tab-btn { padding: 8px 16px; border-radius: 999px; border: 1px solid var(--mp-border); background: rgba(255,255,255,.7); cursor: pointer; font-size: 14px; }
+ .tab-btn.active { background: #111; color: #fff; }
+ .panel { display:none; }
+ .panel.active { display: block; }
+ table { width: 100%; border-collapse: collapse; font-size: 14px; }
+ th, td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--mp-border); }
+ th { color: var(--mp-muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .05em; }
+ .level-bar { height: 10px; background: rgba(20,20,20,.06); border-radius: 999px; overflow: hidden; margin-top:8px; }
+ .level-bar > div { height: 100%; background: linear-gradient(90deg,#111,#bfa76a); transition: width .4s ease; }
+ .upload-card { padding: 20px; background: rgba(255,255,255,.7); border:1px solid var(--mp-border); border-radius: 18px; box-shadow: var(--mp-glass-shadow); }
+ .badges-row { display: flex; flex-wrap: wrap; gap: 8px; }
+ .badge-tile { display:flex; align-items:center; gap:8px; padding:8px 14px; border-radius: 999px; background: rgba(255,255,255,.7); border: 1px solid var(--mp-border); font-size: 13px; }
+</style>
+</head>
+<body>
+ <main class="mp-wrap" id="root">
+ <div id="unauth" style="display:none; padding: 60px 0; text-align:center;">
+ <h2>Not signed in</h2>
+ <p>Apply as a designer or claim your profile to access the dashboard.</p>
+ <a class="mp-btn" href="/marketplace/apply">Apply now</a>
+ </div>
+
+ <section id="authed" style="display:none;">
+ <header style="display:flex; align-items:center; gap: 20px; margin-bottom: 22px;">
+ <div style="flex:1">
+ <h1 id="hello" style="margin: 0">Welcome back</h1>
+ <div style="color: var(--mp-muted)" id="meta"></div>
+ </div>
+ <button class="mp-btn ghost" id="logoutBtn">Log out</button>
+ </header>
+
+ <div class="mp-status-grid" id="kpi"></div>
+
+ <section style="margin: 22px 0;">
+ <strong>Next level</strong>
+ <div id="nextLevel" style="color: var(--mp-muted); margin-top: 4px;"></div>
+ <div class="level-bar"><div id="levelFill" style="width: 0%"></div></div>
+ </section>
+
+ <section class="badges-row" id="badges"></section>
+
+ <div class="tabs" id="tabs">
+ <button class="tab-btn active" data-tab="patterns">Patterns</button>
+ <button class="tab-btn" data-tab="upload">Upload</button>
+ <button class="tab-btn" data-tab="commissions">Commissions</button>
+ <button class="tab-btn" data-tab="points">Points ledger</button>
+ <button class="tab-btn" data-tab="storefront">Storefront</button>
+ </div>
+
+ <section class="panel active" id="panel-patterns">
+ <h3>Your patterns</h3>
+ <div class="mp-grid lg" id="patternsGrid"></div>
+ </section>
+
+ <section class="panel" id="panel-upload">
+ <div class="upload-card">
+ <h3 style="margin-top:0">Upload a pattern</h3>
+ <form id="uploadForm" class="mp-form" enctype="multipart/form-data">
+ <label>Pattern title *<input type="text" name="title" required maxlength="120"/></label>
+ <label>Description<textarea name="description" maxlength="4000"></textarea></label>
+ <div class="row">
+ <label>Repeat type<select name="repeat_type"><option>straight</option><option>half-drop</option><option>mirror</option><option>random</option></select></label>
+ <label>Scale notes<input type="text" name="scale_notes"/></label>
+ </div>
+ <label>Style tags <input type="text" name="style_tags" placeholder="Botanical, Maximalist"/></label>
+ <label>Room tags <input type="text" name="room_tags" placeholder="Powder Room, Bedroom"/></label>
+ <label>Color tags <input type="text" name="color_tags" placeholder="Olive, Plaster"/></label>
+ <div class="row">
+ <label class="check"><input type="checkbox" name="commercial_suitable"/> Commercial-suitable</label>
+ <label class="check"><input type="checkbox" name="hospitality_ready"/> Hospitality-ready</label>
+ </div>
+ <label>Image (≤50MB, JPG/PNG/WEBP) * <input type="file" name="image" required accept="image/*"/></label>
+ <label class="check">
+ <input type="checkbox" name="rights_warranty" required/>
+ I warrant I own or control all rights to this artwork.
+ </label>
+ <button type="submit" class="mp-btn">Submit for review</button>
+ <div id="uploadMsg" class="err" style="display:none"></div>
+ </form>
+ </div>
+ </section>
+
+ <section class="panel" id="panel-commissions">
+ <h3>Commission ledger</h3>
+ <table id="commissionTable"><thead><tr><th>When</th><th>Type</th><th>Basis</th><th>Rate</th><th>Amount</th><th>Status</th></tr></thead><tbody></tbody></table>
+ </section>
+
+ <section class="panel" id="panel-points">
+ <h3>Points history</h3>
+ <table id="pointsTable"><thead><tr><th>When</th><th>Action</th><th>Points</th><th>Notes</th></tr></thead><tbody></tbody></table>
+ </section>
+
+ <section class="panel" id="panel-storefront">
+ <h3>Your public storefront</h3>
+ <p style="color: var(--mp-muted)">Your storefront lives at <code id="storeUrl"></code>. Share this link with clients and trade buyers.</p>
+ <a class="mp-btn" id="visitStore" target="_blank">Visit storefront →</a>
+ </section>
+ </section>
+ </main>
+
+<script>
+async function load() {
+ try {
+ const r = await fetch('/api/marketplace/designer/me', { credentials: 'include' });
+ if (r.status === 401) {
+ document.getElementById('unauth').style.display='block';
+ return;
+ }
+ const j = await r.json();
+ document.getElementById('authed').style.display='block';
+ const d = j.designer;
+ document.getElementById('hello').textContent = `Welcome back, ${d.display_name}`;
+ document.getElementById('meta').textContent = `${d.studio_name || ''} · ${d.level} · ${Number(d.points||0).toLocaleString()} pts`;
+
+ const next = j.next_level;
+ document.getElementById('nextLevel').textContent = next ? `Next level: ${next.name} at ${next.points.toLocaleString()} pts (you’re at ${(d.points||0).toLocaleString()})` : 'Top level reached — Hall of Pattern.';
+ if (next) document.getElementById('levelFill').style.width = (Math.min(100, (d.points||0) / next.points * 100)) + '%';
+ else document.getElementById('levelFill').style.width = '100%';
+
+ const kpi = document.getElementById('kpi');
+ const pending = j.commission_ledger.filter(c => ['pending','approved','payable'].includes(c.status)).reduce((s,c) => s + Number(c.commission_amount), 0);
+ const paid = j.commission_ledger.filter(c => c.status === 'paid').reduce((s,c) => s + Number(c.commission_amount), 0);
+ const items = [
+ ['Patterns', j.patterns.length],
+ ['Approved', j.patterns.filter(p => p.status==='approved').length],
+ ['Pending', j.patterns.filter(p => p.status==='pending').length],
+ ['Points', Number(d.points||0).toLocaleString()],
+ ['Pending $', '$'+pending.toFixed(2)],
+ ['Paid $', '$'+paid.toFixed(2)],
+ ];
+ kpi.innerHTML='';
+ items.forEach(([l,v]) => { const c = document.createElement('div'); c.className='mp-stat'; c.innerHTML=`<div class="label">${l}</div><div class="value">${v}</div>`; kpi.appendChild(c); });
+
+ const badges = document.getElementById('badges'); badges.innerHTML='';
+ j.badges.forEach(b => { const e = document.createElement('div'); e.className='badge-tile'; e.innerHTML=`<span>${b.icon || '★'}</span> ${b.badge_name}`; badges.appendChild(e); });
+
+ const pg = document.getElementById('patternsGrid'); pg.innerHTML='';
+ j.patterns.forEach(p => {
+ const card = document.createElement('div'); card.className='mp-card';
+ card.innerHTML=`<div class="img" style="background-image:url('${p.thumbnail_url||''}')"></div>
+ <div class="body">
+ <div class="title">${p.title}</div>
+ <div class="sub">${p.status} · ${p.view_count} views · ${p.save_count} saves</div>
+ <div class="row">
+ <span class="mp-pill ${p.status==='approved'?'verified': (p.status==='pending'?'':'')}">${p.status}</span>
+ <a class="mp-btn tiny ghost" href="/patterns/${p.slug}" target="_blank">View</a>
+ </div>
+ </div>`;
+ pg.appendChild(card);
+ });
+
+ const ct = document.getElementById('commissionTable').querySelector('tbody'); ct.innerHTML='';
+ j.commission_ledger.forEach(c => {
+ const tr = document.createElement('tr');
+ tr.innerHTML = `<td>${new Date(c.created_at).toLocaleDateString()}</td><td>${c.entry_type}</td><td>$${Number(c.basis_amount).toFixed(2)}</td><td>${c.commission_rate}%</td><td><strong>$${Number(c.commission_amount).toFixed(2)}</strong></td><td><span class="mp-pill ${c.status==='paid'?'verified':''}">${c.status}</span></td>`;
+ ct.appendChild(tr);
+ });
+
+ const pt = document.getElementById('pointsTable').querySelector('tbody'); pt.innerHTML='';
+ j.points_ledger.forEach(p => {
+ const tr = document.createElement('tr');
+ tr.innerHTML = `<td>${new Date(p.created_at).toLocaleDateString()}</td><td>${p.action}</td><td>+${p.points}</td><td>${p.notes || ''}</td>`;
+ pt.appendChild(tr);
+ });
+
+ const storeUrl = `${location.origin}/store/${d.slug}`;
+ document.getElementById('storeUrl').textContent = storeUrl;
+ document.getElementById('visitStore').href = `/store/${d.slug}`;
+
+ } catch (err) {
+ document.getElementById('unauth').style.display='block';
+ console.error(err);
+ }
+}
+
+// tabs
+document.addEventListener('click', (e) => {
+ const t = e.target.closest('.tab-btn'); if(!t) return;
+ document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
+ document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
+ t.classList.add('active');
+ document.getElementById('panel-' + t.dataset.tab).classList.add('active');
+});
+
+document.getElementById('logoutBtn').addEventListener('click', async () => {
+ await fetch('/api/marketplace/designer/logout', { method:'POST', credentials:'include' });
+ location.reload();
+});
+
+document.getElementById('uploadForm').addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const msg = document.getElementById('uploadMsg'); msg.style.display='none';
+ const fd = new FormData(e.target);
+ fd.set('commercial_suitable', e.target.commercial_suitable.checked ? 'true' : 'false');
+ fd.set('hospitality_ready', e.target.hospitality_ready.checked ? 'true' : 'false');
+ fd.set('rights_warranty', e.target.rights_warranty.checked ? 'true' : 'false');
+ try {
+ const r = await fetch('/api/marketplace/patterns/upload', { method:'POST', credentials:'include', body: fd });
+ const j = await r.json();
+ if(!r.ok) throw new Error(j.error || 'upload failed');
+ msg.className='ok'; msg.style.display='block'; msg.textContent = 'Uploaded · pending admin approval.';
+ setTimeout(load, 500);
+ e.target.reset();
+ } catch (err) {
+ msg.className='err'; msg.style.display='block'; msg.textContent = err.message;
+ }
+});
+
+load();
+</script>
+</body>
+</html>
diff --git a/public/marketplace/designer-profile.html b/public/marketplace/designer-profile.html
new file mode 100644
index 0000000..3c2b96f
--- /dev/null
+++ b/public/marketplace/designer-profile.html
@@ -0,0 +1,96 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<title>Designer · Wallco</title>
+<meta name="viewport" content="width=device-width,initial-scale=1" />
+<link rel="stylesheet" href="/marketplace/_layout.css" />
+<script src="/marketplace/_partials.js" defer></script>
+</head>
+<body>
+ <main id="root"><div class="mp-wrap"><p>Loading…</p></div></main>
+
+<script>
+const slug = location.pathname.split('/').filter(Boolean).pop();
+async function load() {
+ const j = await fetch('/api/marketplace/designers/' + encodeURIComponent(slug)).then(r => r.json());
+ const root = document.getElementById('root');
+ root.innerHTML = '';
+ if (j.claim && !j.designer) {
+ // unclaimed page
+ root.innerHTML = `
+ <header class="mp-storefront-hero">
+ <div class="avatar" style="background: linear-gradient(135deg,#ddd,#bbb);"></div>
+ <div>
+ <h1>${j.claim.public_name}</h1>
+ <div class="studio">Unclaimed designer profile</div>
+ </div>
+ </header>
+ <div class="mp-wrap">
+ <div class="mp-claim-banner">
+ <h3>Are you ${j.claim.public_name}?</h3>
+ <div class="small">${j.claim.disclaimer}</div>
+ <a class="mp-btn" href="/marketplace/claim?slug=${j.claim.slug}">Claim this page</a>
+ </div>
+ <section class="mp-section">
+ <h2>What you'll get by claiming</h2>
+ <div class="mp-grid lg">
+ <div class="mp-card"><div class="body"><strong>Storefront</strong><div class="sub">/store/${j.claim.slug}</div></div></div>
+ <div class="mp-card"><div class="body"><strong>Pattern uploads</strong><div class="sub">With AI colorways and room mockups</div></div></div>
+ <div class="mp-card"><div class="body"><strong>Commissions</strong><div class="sub">15–20% wallpaper royalty, 60–70% licensing</div></div></div>
+ <div class="mp-card"><div class="body"><strong>Levels & badges</strong><div class="sub">Founding Designer, Verified, Hospitality Ready, more</div></div></div>
+ </div>
+ </section>
+ </div>`;
+ return;
+ }
+ if (!j.designer) { root.innerHTML = `<div class="mp-wrap"><h2>Not found</h2></div>`; return; }
+ const d = j.designer;
+ const badgePills = (j.badges || []).map(b => `<span class="mp-pill">${b.icon || '★'} ${b.badge_name}</span>`).join(' ');
+ root.innerHTML = `
+ <header class="mp-storefront-hero">
+ <div class="avatar" style="background-image: linear-gradient(135deg,#ddd,#bbb); background-size: cover;"></div>
+ <div>
+ <h1>${d.display_name}</h1>
+ <div class="studio">${d.studio_name || ''} · ${d.location || ''}</div>
+ <div class="badges">${badgePills}</div>
+ <div class="meta">
+ <span class="mp-pill ${d.is_founding ? 'gold' : ''}">${d.level}</span>
+ ${d.is_verified ? '<span class="mp-pill verified">Verified</span>' : ''}
+ <span>${Number(d.points||0).toLocaleString()} pts</span>
+ <span>${(j.patterns || []).length} patterns</span>
+ <a class="mp-btn tiny" href="/store/${d.slug}">Visit storefront →</a>
+ </div>
+ </div>
+ </header>
+
+ <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>` : ''}
+ ${(j.collections||[]).length ? `<section class="mp-section">
+ <h2>Collections</h2>
+ <div class="mp-grid lg">
+ ${j.collections.map(c => `<a class="mp-card" href="#"><div class="img" style="background: linear-gradient(135deg,#eee,#ddd)"></div><div class="body"><div class="title">${c.title}</div></div></a>`).join('')}
+ </div>
+ </section>` : ''}
+ <section class="mp-section">
+ <h2>Patterns</h2>
+ <div class="mp-grid lg" id="pat"></div>
+ </section>
+ </div>`;
+
+ const pat = document.getElementById('pat');
+ (j.patterns || []).forEach(p => {
+ const a = document.createElement('a'); a.className='mp-card'; a.href=`/patterns/${p.slug}`;
+ a.innerHTML = `
+ <div class="img" style="background-image:url('${p.thumbnail_url || p.original_image_url}')"></div>
+ <div class="body">
+ <div class="title">${p.title}</div>
+ <div class="sub">${(p.style_tags||[]).slice(0,2).join(' · ')}</div>
+ </div>`;
+ pat.appendChild(a);
+ });
+}
+load();
+</script>
+</body>
+</html>
diff --git a/public/marketplace/designers.html b/public/marketplace/designers.html
new file mode 100644
index 0000000..c7be387
--- /dev/null
+++ b/public/marketplace/designers.html
@@ -0,0 +1,77 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<title>Designers · Wallco Marketplace</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>
+ .controls { display:flex; gap: 10px; flex-wrap:wrap; margin: 14px 0 22px; align-items:center; }
+ .controls select { padding: 8px 12px; border-radius: 999px; border: 1px solid var(--mp-border); background: rgba(255,255,255,.8); font-size: 14px; }
+ .controls input[type=range] { flex: 0 0 200px; }
+</style>
+</head>
+<body>
+ <header class="mp-hero">
+ <h1>Designers</h1>
+ <p class="lede">Independent creators publishing on Wallco. Each designer has a storefront, collections, and AI-generated colorways. Founding designers earn 20% royalty on wallpaper.</p>
+ </header>
+ <main class="mp-wrap">
+ <div class="controls">
+ <span class="sub">Sort</span>
+ <select id="sort">
+ <option value="featured">Featured first</option>
+ <option value="points">Highest points</option>
+ <option value="newest">Newest</option>
+ </select>
+ <span class="sub" style="margin-left: 14px">Density</span>
+ <input type="range" id="density" min="180" max="380" value="280" step="10"/>
+ </div>
+ <div class="mp-grid lg" id="grid"></div>
+ </main>
+
+<script>
+const grid = document.getElementById('grid');
+const sortSel = document.getElementById('sort');
+const density = document.getElementById('density');
+
+// persist
+const KEY = 'wallco.marketplace.designers';
+const saved = JSON.parse(localStorage.getItem(KEY) || '{}');
+if (saved.sort) sortSel.value = saved.sort;
+if (saved.density) density.value = saved.density;
+applyDensity();
+
+function applyDensity() {
+ grid.style.gridTemplateColumns = `repeat(auto-fill, minmax(${density.value}px, 1fr))`;
+}
+
+async function load() {
+ const sort = sortSel.value;
+ localStorage.setItem(KEY, JSON.stringify({ sort, density: density.value }));
+ const j = await fetch(`/api/marketplace/designers?sort=${sort}&limit=120`).then(r => r.json());
+ grid.innerHTML = '';
+ (j.designers || []).forEach(d => {
+ const a = document.createElement('a'); a.className='mp-card'; a.href=`/designers/${d.slug}`;
+ 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.style_tags||[]).slice(0,2).join(' · ')}</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>' : ''}
+ <span class="sub">${Number(d.points||0).toLocaleString()} pts</span>
+ </div>
+ </div>`;
+ grid.appendChild(a);
+ });
+}
+
+sortSel.addEventListener('change', load);
+density.addEventListener('input', () => { applyDensity(); localStorage.setItem(KEY, JSON.stringify({ sort: sortSel.value, density: density.value })); });
+load();
+</script>
+</body>
+</html>
diff --git a/public/marketplace/index.html b/public/marketplace/index.html
new file mode 100644
index 0000000..27ba201
--- /dev/null
+++ b/public/marketplace/index.html
@@ -0,0 +1,124 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<title>Wallco · Designer Marketplace</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>
+</head>
+<body>
+ <header class="mp-hero">
+ <h1>Where pattern designers become brands.</h1>
+ <p class="lede">Wallco is the AI-powered marketplace for designer-led wallcoverings. Independent creators upload patterns, get instant colorways, build storefronts, and earn commissions from samples, full orders, and licensing — all backed by Designer Wallcoverings production.</p>
+ <div class="cta-row">
+ <a class="mp-btn" href="/marketplace/apply">Become a Wallco Designer</a>
+ <a class="mp-btn ghost" href="/designers">Browse Designers</a>
+ <a class="mp-btn ghost" href="/patterns">Browse Patterns</a>
+ </div>
+ </header>
+
+ <main class="mp-wrap">
+
+ <section class="mp-section">
+ <h2>The marketplace, by the numbers</h2>
+ <div class="mp-status-grid" id="stats"></div>
+ </section>
+
+ <section class="mp-section">
+ <h2>Featured designers</h2>
+ <div class="sub">Founding creators publishing collections, colorways, and licensing inventory on Wallco.</div>
+ <div class="mp-grid lg" id="designers"></div>
+ </section>
+
+ <section class="mp-section">
+ <h2>Featured patterns</h2>
+ <div class="sub">Designer-led wallcoverings. Sample first, license, or specify for trade.</div>
+ <div class="mp-grid lg" id="patterns"></div>
+ </section>
+
+ <section class="mp-section">
+ <h2>What designers get</h2>
+ <div class="mp-grid lg">
+ <div class="mp-card"><div class="body"><strong>Your storefront</strong><div class="sub">wallco.ai/store/your-slug. Hero, collections, samples, licensing inquiries, custom-design CTA.</div></div></div>
+ <div class="mp-card"><div class="body"><strong>AI colorways</strong><div class="sub">Six instant palettes per pattern — warm clay, moss & linen, ink blue, rose plaster, gold smoke, original.</div></div></div>
+ <div class="mp-card"><div class="body"><strong>Transparent commissions</strong><div class="sub">Net-revenue royalty, ledger-tracked. Founding designers earn 20% on wallpaper, 60–70% on licensing.</div></div></div>
+ <div class="mp-card"><div class="body"><strong>Levels & badges</strong><div class="sub">From New Creator → Pattern Maker → Featured → Collection Artist → Trade Favorite → Wallco Signature → Hall of Pattern.</div></div></div>
+ <div class="mp-card"><div class="body"><strong>Trade exposure</strong><div class="sub">Interior designers save patterns to project boards, order samples, request custom recolors and murals.</div></div></div>
+ <div class="mp-card"><div class="body"><strong>Production-grade</strong><div class="sub">Backed by Designer Wallcoverings — Type II commercial, peel-and-stick, non-woven, grasscloth substrates.</div></div></div>
+ </div>
+ </section>
+
+ <section class="mp-section">
+ <h2>The model, in plain language</h2>
+ <div class="mp-grid lg">
+ <div class="mp-card"><div class="body"><strong>Patternbank-style licensing</strong><div class="sub">Designers earn from non-exclusive and exclusive licensing deals. Every inquiry tracked, every contract attributable.</div></div></div>
+ <div class="mp-card"><div class="body"><strong>Material Bank-style sampling</strong><div class="sub">Trade buyers order samples, save to project boards, get client approval — Wallco fulfills.</div></div></div>
+ <div class="mp-card"><div class="body"><strong>Spoonflower-style storefronts</strong><div class="sub">Every approved designer gets a personal storefront, collections, and follow-button discovery.</div></div></div>
+ <div class="mp-card"><div class="body"><strong>Wallco × AI</strong><div class="sub">Auto-tag, auto-colorway, auto-mockup. One pattern becomes a sellable family of SKUs.</div></div></div>
+ </div>
+ </section>
+
+ </main>
+
+<script>
+async function loadHome() {
+ const status = await fetch('/api/marketplace/status').then(r => r.json());
+ const grid = document.getElementById('stats');
+ const STATS = [
+ ['designers_approved', 'Approved designers'],
+ ['patterns_approved', 'Approved patterns'],
+ ['collections', 'Collections'],
+ ['commission_entries', 'Commission events'],
+ ['pending_commission', 'Pending $ to designers', '$'],
+ ['unclaimed', 'Awaiting claim'],
+ ];
+ STATS.forEach(([k,label,prefix]) => {
+ const v = status.counts[k] || 0;
+ const card = document.createElement('div'); card.className = 'mp-stat';
+ const display = (prefix === '$') ? ('$' + Number(v).toLocaleString(undefined,{maximumFractionDigits:2})) : Number(v).toLocaleString();
+ card.innerHTML = `<div class="label">${label}</div><div class="value">${display}</div>`;
+ grid.appendChild(card);
+ });
+
+ const designers = await fetch('/api/marketplace/designers?sort=featured&limit=6').then(r => r.json());
+ const dWrap = document.getElementById('designers');
+ (designers.designers || []).forEach(d => {
+ const card = document.createElement('a');
+ card.className = 'mp-card'; card.href = '/designers/' + d.slug;
+ card.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 || ''}</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>' : ''}
+ </div>
+ <div class="sub" style="margin-top:4px">${(d.style_tags||[]).slice(0,3).join(' · ')}</div>
+ </div>`;
+ dWrap.appendChild(card);
+ });
+
+ const patterns = await fetch('/api/marketplace/patterns?limit=12').then(r => r.json());
+ const pWrap = document.getElementById('patterns');
+ (patterns.patterns || []).forEach(p => {
+ const card = document.createElement('a');
+ card.className = 'mp-card'; card.href = '/patterns/' + p.slug;
+ card.innerHTML = `
+ <div class="img" style="background-image:url('${p.thumbnail_url}')"></div>
+ <div class="body">
+ <div class="title">${p.title}</div>
+ <div class="sub">by ${p.designer_name}</div>
+ <div class="row">
+ ${(p.style_tags||[]).slice(0,2).map(t => `<span class="mp-pill">${t}</span>`).join(' ')}
+ ${p.featured ? '<span class="mp-pill gold">Featured</span>' : ''}
+ </div>
+ </div>`;
+ pWrap.appendChild(card);
+ });
+}
+loadHome();
+</script>
+</body>
+</html>
diff --git a/public/marketplace/licensing.html b/public/marketplace/licensing.html
new file mode 100644
index 0000000..b6f2e85
--- /dev/null
+++ b/public/marketplace/licensing.html
@@ -0,0 +1,79 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<title>License artwork · Wallco</title>
+<meta name="viewport" content="width=device-width,initial-scale=1" />
+<link rel="stylesheet" href="/marketplace/_layout.css" />
+<script src="/marketplace/_partials.js" defer></script>
+</head>
+<body>
+ <header class="mp-hero">
+ <h1>License artwork.</h1>
+ <p class="lede">Non-exclusive, exclusive, editorial, or print-on-demand. Wallco routes inquiries to the designer and handles contract + payment.</p>
+ </header>
+ <main class="mp-wrap">
+ <div id="info" class="sub" style="margin-bottom: 14px;"></div>
+ <section class="mp-card" style="padding: 22px;">
+ <form id="form" class="mp-form">
+ <div class="row">
+ <label>Your name *<input type="text" name="buyer_name" required maxlength="100"/></label>
+ <label>Email *<input type="email" name="buyer_email" required maxlength="200"/></label>
+ </div>
+ <label>Company <input type="text" name="buyer_company" maxlength="200"/></label>
+ <div class="row">
+ <label>License type *
+ <select name="license_type" required>
+ <option value="non_exclusive">Non-exclusive</option>
+ <option value="exclusive">Exclusive</option>
+ <option value="editorial">Editorial</option>
+ <option value="print_on_demand">Print on demand</option>
+ <option value="custom">Custom</option>
+ </select>
+ </label>
+ <label>Duration (months) <input type="number" name="duration_months" min="1" max="120"/></label>
+ </div>
+ <label>Territory <input type="text" name="territory" placeholder="US, EU, Worldwide"/></label>
+ <label>Budget range <input type="text" name="budget_range" placeholder="e.g. $5,000–$10,000"/></label>
+ <label>Usage description *
+ <textarea name="usage_description" required maxlength="4000" placeholder="How will the artwork be used? Hospitality wallcovering, packaging, hotel suite collection, editorial spread, etc."></textarea>
+ </label>
+ <button type="submit" class="mp-btn">Send inquiry</button>
+ <div id="msg" class="err" style="display:none"></div>
+ </form>
+ </section>
+ </main>
+
+<script>
+const slug = location.pathname.split('/').filter(Boolean).pop();
+(async () => {
+ try {
+ const j = await fetch('/api/marketplace/patterns/' + encodeURIComponent(slug)).then(r => r.json());
+ if (j.pattern) {
+ document.getElementById('info').innerHTML = `Inquiring about <strong>${j.pattern.title}</strong> by <a class="under" href="/designers/${j.pattern.designer_slug}">${j.pattern.designer_name}</a>`;
+ } else {
+ // it's a designer slug; show their info
+ const d = await fetch('/api/marketplace/designers/' + encodeURIComponent(slug)).then(r => r.json());
+ if (d.designer) document.getElementById('info').innerHTML = `Inquiring about <strong>${d.designer.display_name}</strong>'s catalog`;
+ }
+ } catch(_){}
+})();
+
+document.getElementById('form').addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const msg = document.getElementById('msg'); msg.style.display='none';
+ const body = Object.fromEntries(new FormData(e.target).entries());
+ body.pattern_slug = slug;
+ try {
+ const r = await fetch('/api/marketplace/licensing/inquiry', { method:'POST', credentials:'include', headers:{'content-type':'application/json'}, body: JSON.stringify(body) });
+ const j = await r.json();
+ if(!r.ok) throw new Error(j.error || 'failed');
+ msg.className='ok'; msg.style.display='block'; msg.textContent = `Inquiry #${j.inquiry_id} sent. The designer + Wallco team will respond within 2 business days.`;
+ e.target.reset();
+ } catch (err) {
+ msg.className='err'; msg.style.display='block'; msg.textContent = err.message;
+ }
+});
+</script>
+</body>
+</html>
diff --git a/public/marketplace/pattern.html b/public/marketplace/pattern.html
new file mode 100644
index 0000000..caed7d6
--- /dev/null
+++ b/public/marketplace/pattern.html
@@ -0,0 +1,86 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<title>Pattern · Wallco</title>
+<meta name="viewport" content="width=device-width,initial-scale=1" />
+<link rel="stylesheet" href="/marketplace/_layout.css" />
+<script src="/marketplace/_partials.js" defer></script>
+<style>
+ .pat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 30px; }
+ @media (max-width: 800px){ .pat-grid { grid-template-columns: 1fr; } }
+ .hero-pat { aspect-ratio: 1/1; border-radius: 24px; background: #eee; background-size: cover; background-position: center; box-shadow: var(--mp-glass-shadow); }
+ .swatch-row { display: flex; gap: 6px; flex-wrap: wrap; margin: 8px 0 14px; }
+ .swatch { width: 36px; height: 36px; border-radius: 8px; border: 1px solid var(--mp-border); box-shadow: 0 0 0 2px #fff inset; }
+ .colorway-card { padding: 14px; background: rgba(255,255,255,.7); border: 1px solid var(--mp-border); border-radius: 16px; }
+ .colorway-card .name { font-weight: 600; margin-bottom: 6px; }
+ .specs { display:grid; grid-template-columns: 130px 1fr; gap: 4px 18px; font-size: 14px; color: var(--mp-muted); margin: 14px 0 22px; }
+ .specs .k { color: #222; }
+</style>
+</head>
+<body>
+ <main id="root"><div class="mp-wrap"><p>Loading…</p></div></main>
+
+<script>
+const slug = location.pathname.split('/').filter(Boolean).pop();
+async function load() {
+ const j = await fetch('/api/marketplace/patterns/' + encodeURIComponent(slug)).then(r => r.json());
+ const root = document.getElementById('root');
+ if (!j.pattern) { root.innerHTML = `<div class="mp-wrap"><h2>Pattern not found</h2></div>`; return; }
+ const p = j.pattern;
+ root.innerHTML = `
+ <main class="mp-wrap">
+ <div class="pat-grid">
+ <div>
+ <div class="hero-pat" style="background-image:url('${p.original_image_url}')"></div>
+ <div style="display:flex; gap: 6px; margin-top: 10px; flex-wrap: wrap;" id="thumbs"></div>
+ </div>
+ <div>
+ <div class="sub">by <a class="under" href="/designers/${p.designer_slug}">${p.designer_name}</a></div>
+ <h1 style="margin: 6px 0;">${p.title}</h1>
+ <p style="color: var(--mp-muted); line-height: 1.6">${p.description || ''}</p>
+
+ <div class="swatch-row" id="swatches"></div>
+
+ <div class="specs">
+ <div class="k">Repeat type</div><div>${p.repeat_type || '—'}</div>
+ <div class="k">Style</div><div>${(p.style_tags||[]).join(' · ') || '—'}</div>
+ <div class="k">Best for</div><div>${(p.room_tags||[]).join(' · ') || '—'}</div>
+ <div class="k">Color family</div><div>${(p.color_tags||[]).join(' · ') || '—'}</div>
+ <div class="k">Commercial</div><div>${p.commercial_suitable ? 'Yes (Type II contract grade available)' : 'Residential first'}</div>
+ <div class="k">Rights</div><div>${p.rights_confirmed ? 'Designer-warranted' : 'Pending'}</div>
+ </div>
+
+ <div style="display:flex; gap: 10px; flex-wrap: wrap;">
+ <button class="mp-btn" onclick="orderSample()">Order sample · $${(p.sample_price||5).toFixed(2)}</button>
+ <button class="mp-btn ghost" onclick="orderRoll()">Order full roll · $${(p.base_price||248).toFixed(2)}</button>
+ <a class="mp-btn ghost" href="/licensing/${p.slug}">License artwork</a>
+ <button class="mp-btn ghost" onclick="aiColorways()">Generate AI colorways</button>
+ </div>
+
+ <h3 style="margin-top: 30px">Colorways</h3>
+ <div class="mp-grid" id="cw"></div>
+ </div>
+ </div>
+ </main>`;
+
+ const cw = document.getElementById('cw');
+ (j.colorways || []).forEach(c => {
+ const palette = Array.isArray(c.hex_palette) ? c.hex_palette : (c.hex_palette ? JSON.parse(c.hex_palette) : []);
+ const card = document.createElement('div'); card.className='colorway-card';
+ card.innerHTML = `<div class="name">${c.name}${c.is_primary ? ' · primary' : ''}</div>
+ <div class="swatch-row">${palette.map(h => `<div class="swatch" style="background:${h}" title="${h}"></div>`).join('')}</div>`;
+ cw.appendChild(card);
+ });
+}
+window.orderSample = function() { alert('Sample order — wires to Designer Wallcoverings fulfillment ($5 ships in 3–5 days).'); };
+window.orderRoll = function() { alert('Full-roll order — wires to Shopify checkout.'); };
+window.aiColorways = async function() {
+ const r = await fetch('/api/marketplace/ai/generate-colorways', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ patternSlug: slug }) });
+ const j = await r.json();
+ alert('AI generated ' + (j.colorways?.length || 0) + ' colorways. (Wired to Gemini/Ollama in next release.)');
+};
+load();
+</script>
+</body>
+</html>
diff --git a/public/marketplace/patterns.html b/public/marketplace/patterns.html
new file mode 100644
index 0000000..e3aa2ef
--- /dev/null
+++ b/public/marketplace/patterns.html
@@ -0,0 +1,81 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<title>Patterns · Wallco Marketplace</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>
+ .controls { display:flex; gap: 10px; flex-wrap:wrap; margin: 14px 0 22px; align-items:center; }
+ .controls select { padding: 8px 12px; border-radius: 999px; border: 1px solid var(--mp-border); background: rgba(255,255,255,.8); font-size: 14px; }
+</style>
+</head>
+<body>
+ <header class="mp-hero">
+ <h1>Patterns</h1>
+ <p class="lede">Every pattern is uploaded by an independent designer, rights-confirmed, and produced through Designer Wallcoverings. Sort, filter, sample.</p>
+ </header>
+ <main class="mp-wrap">
+ <div class="controls">
+ <span class="sub">Sort</span>
+ <select id="sort"><option value="featured">Featured first</option><option value="newest">Newest</option></select>
+ <span class="sub">Designer</span>
+ <select id="designer"><option value="">All</option></select>
+ <span class="sub" style="margin-left:14px">Density</span>
+ <input type="range" id="density" min="200" max="380" value="280" step="10"/>
+ </div>
+ <div class="mp-grid lg" id="grid"></div>
+ </main>
+
+<script>
+const grid = document.getElementById('grid');
+const sortSel = document.getElementById('sort');
+const dSel = document.getElementById('designer');
+const density = document.getElementById('density');
+
+const KEY = 'wallco.marketplace.patterns';
+const saved = JSON.parse(localStorage.getItem(KEY) || '{}');
+if (saved.density) density.value = saved.density;
+applyDensity();
+
+function applyDensity() { grid.style.gridTemplateColumns = `repeat(auto-fill, minmax(${density.value}px, 1fr))`; }
+
+async function loadDesigners() {
+ const j = await fetch('/api/marketplace/designers?limit=120').then(r => r.json());
+ (j.designers || []).forEach(d => {
+ const o = document.createElement('option'); o.value=d.slug; o.textContent=d.display_name; dSel.appendChild(o);
+ });
+}
+async function load() {
+ localStorage.setItem(KEY, JSON.stringify({ density: density.value }));
+ const params = new URLSearchParams({ limit: '120' });
+ if (dSel.value) params.set('designer', dSel.value);
+ const j = await fetch('/api/marketplace/patterns?' + params).then(r => r.json());
+ grid.innerHTML = '';
+ let patterns = j.patterns || [];
+ if (sortSel.value === 'newest') {
+ // server already returns featured first; for newest just keep order
+ }
+ patterns.forEach(p => {
+ const a = document.createElement('a'); a.className='mp-card'; a.href=`/patterns/${p.slug}`;
+ a.innerHTML = `
+ <div class="img" style="background-image:url('${p.thumbnail_url}')"></div>
+ <div class="body">
+ <div class="title">${p.title}</div>
+ <div class="sub">by ${p.designer_name}</div>
+ <div class="row">
+ ${(p.style_tags||[]).slice(0,2).map(t => `<span class="mp-pill">${t}</span>`).join(' ')}
+ ${p.featured ? '<span class="mp-pill gold">Featured</span>' : ''}
+ </div>
+ </div>`;
+ grid.appendChild(a);
+ });
+}
+sortSel.addEventListener('change', load);
+dSel.addEventListener('change', load);
+density.addEventListener('input', () => { applyDensity(); localStorage.setItem(KEY, JSON.stringify({ density: density.value })); });
+loadDesigners().then(load);
+</script>
+</body>
+</html>
diff --git a/public/marketplace/status.html b/public/marketplace/status.html
new file mode 100644
index 0000000..55488e8
--- /dev/null
+++ b/public/marketplace/status.html
@@ -0,0 +1,199 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<title>Wallco Marketplace · Live Build Status</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>
+ .status-meta { display:flex; gap:12px; align-items:center; font-size: 13px; color: var(--mp-muted); margin-top: 6px; }
+ .dot { width:10px; height:10px; border-radius:50%; background:#0a7; display:inline-block; animation: pulse 1.4s infinite; }
+ @keyframes pulse { 50% { opacity: .35; } }
+ .latest { display: grid; gap: 12px; margin-top: 16px; }
+ .row-item { display:flex; gap: 14px; align-items:center; padding: 10px 14px; background: rgba(255,255,255,.6); border:1px solid var(--mp-border); border-radius: 14px; }
+ .row-item .thumb { width: 56px; height: 56px; border-radius: 10px; background: #eee; background-size: cover; background-position: center; flex: 0 0 56px; }
+ .row-item .meta-r { font-size: 12px; color: var(--mp-muted); }
+ pre.json { background: #111; color: #cfe; padding: 14px; border-radius: 14px; font-size: 12px; overflow:auto; max-height: 300px; }
+ .build-log { padding: 14px 16px; background: #0c0c0e; color: #d8d8d8; border-radius: 16px; font: 12px/1.55 ui-monospace, Menlo, monospace; height: 360px; overflow: auto; }
+ .build-log .ok { color: #6ce892; }
+ .build-log .warn{ color: #f3c969; }
+ .build-log .err { color: #f08a8a; }
+ .checks { display:grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 10px; margin-top: 10px; }
+ .check-row { display:flex; align-items:center; gap:8px; font-size:13px; }
+ .check-row .pill-ok { background:#0a7; color:#fff; padding: 2px 8px; border-radius: 999px; font-size: 11px; }
+ .check-row .pill-fail{ background:#c33; color:#fff; padding: 2px 8px; border-radius: 999px; font-size: 11px; }
+</style>
+</head>
+<body>
+ <header class="mp-hero">
+ <h1>Live build status</h1>
+ <p class="lede">What's running, what's seeded, what's earning. Auto-refreshing every 4 seconds. This is the "show what's happening" board Steve asked for.</p>
+ <div class="status-meta"><span class="dot" id="dot"></span><span id="liveText">Live • polling /api/marketplace/status</span></div>
+ </header>
+
+ <main class="mp-wrap">
+
+ <section class="mp-status-board">
+ <h2 style="margin-top:0">Counts</h2>
+ <div class="mp-status-grid" id="stats"></div>
+ </section>
+
+ <section class="mp-section">
+ <h2>Endpoint health</h2>
+ <div class="sub">Smoke-test every marketplace endpoint and show green/red live.</div>
+ <div class="checks" id="checks"></div>
+ </section>
+
+ <section class="mp-section">
+ <h2>Latest activity</h2>
+ <div class="sub">Most-recent designer signups, patterns, commission events, and points earned.</div>
+ <div class="latest" id="latest"></div>
+ </section>
+
+ <section class="mp-section">
+ <h2>Raw status payload</h2>
+ <pre class="json" id="raw"></pre>
+ </section>
+
+ <section class="mp-section">
+ <h2>Build log</h2>
+ <div class="sub">Append-only stream of what just got built. Refreshes from server's pm2 log when available.</div>
+ <div class="build-log" id="log"></div>
+ </section>
+
+ </main>
+
+<script>
+const ENDPOINTS = [
+ ['GET', '/api/marketplace/status'],
+ ['GET', '/api/marketplace/designers'],
+ ['GET', '/api/marketplace/patterns'],
+ ['GET', '/api/marketplace/designers/astrid-mauve'],
+ ['POST', '/api/marketplace/commissions/calculate', { lineItemPrice: 496, commissionRate: 15 }],
+ ['POST', '/api/marketplace/ai/generate-colorways', { patternSlug: 'wildflower-reverie-astrid-mauve' }],
+ ['POST', '/api/marketplace/ai/tag-pattern', {}],
+ ['POST', '/api/marketplace/ai/write-pattern-description', { title: 'Wildflower Reverie' }],
+];
+
+const STATS = [
+ ['designers', 'Designers'],
+ ['designers_approved', 'Approved'],
+ ['founding', 'Founding'],
+ ['unclaimed', 'Unclaimed pages'],
+ ['patterns', 'Patterns'],
+ ['patterns_approved', 'Approved patterns'],
+ ['patterns_pending', 'Pending review'],
+ ['collections', 'Published collections'],
+ ['commission_entries', 'Commission events'],
+ ['pending_commission', 'Pending $ owed', '$'],
+ ['paid_commission', 'Paid $ to date', '$'],
+ ['open_inquiries', 'Open license inquiries'],
+ ['open_takedowns', 'Open takedowns'],
+];
+
+function logLine(text, cls) {
+ const log = document.getElementById('log');
+ const line = document.createElement('div');
+ const t = new Date().toLocaleTimeString();
+ line.innerHTML = `<span style="color:#888">${t}</span> <span class="${cls||''}">${text}</span>`;
+ log.appendChild(line);
+ log.scrollTop = log.scrollHeight;
+}
+
+async function refreshStats() {
+ try {
+ const r = await fetch('/api/marketplace/status', { credentials: 'include' });
+ const j = await r.json();
+ document.getElementById('raw').textContent = JSON.stringify(j, null, 2);
+ const grid = document.getElementById('stats');
+ grid.innerHTML = '';
+ for (const [k,label,prefix] of STATS) {
+ const v = j.counts && j.counts[k] != null ? j.counts[k] : '–';
+ const card = document.createElement('div');
+ card.className = 'mp-stat';
+ const display = (prefix === '$') ? ('$' + Number(v).toLocaleString(undefined,{maximumFractionDigits:2})) : Number(v).toLocaleString();
+ card.innerHTML = `<div class="label">${label}</div><div class="value">${display}</div>`;
+ grid.appendChild(card);
+ }
+ document.getElementById('liveText').textContent = 'Live • db ' + (j.db ? 'up' : 'DOWN') + ' • ' + new Date(j.ts).toLocaleTimeString();
+ document.getElementById('dot').style.background = j.db ? '#0a7' : '#c33';
+ } catch (err) {
+ logLine('status fetch failed: ' + err.message, 'err');
+ document.getElementById('dot').style.background = '#c33';
+ }
+}
+
+async function refreshChecks() {
+ const grid = document.getElementById('checks');
+ grid.innerHTML = '';
+ for (const ep of ENDPOINTS) {
+ const [method, url, body] = ep;
+ const row = document.createElement('div');
+ row.className = 'check-row';
+ row.innerHTML = `<code>${method} ${url}</code>`;
+ grid.appendChild(row);
+ try {
+ const opts = { method, credentials: 'include', headers: { 'content-type': 'application/json' } };
+ if (body) opts.body = JSON.stringify(body);
+ const r = await fetch(url, opts);
+ const ok = r.ok;
+ const pill = document.createElement('span');
+ pill.className = ok ? 'pill-ok' : 'pill-fail';
+ pill.textContent = ok ? ('OK ' + r.status) : ('FAIL ' + r.status);
+ row.appendChild(pill);
+ logLine(`${method} ${url} → ${r.status}`, ok ? 'ok' : 'err');
+ } catch (err) {
+ const pill = document.createElement('span');
+ pill.className = 'pill-fail';
+ pill.textContent = 'ERR';
+ row.appendChild(pill);
+ logLine(`${method} ${url} → ${err.message}`, 'err');
+ }
+ }
+}
+
+async function refreshLatest() {
+ try {
+ const [designers, patterns] = await Promise.all([
+ fetch('/api/marketplace/designers?sort=newest&limit=4').then(r => r.json()),
+ fetch('/api/marketplace/patterns?limit=4').then(r => r.json()),
+ ]);
+ const out = document.getElementById('latest');
+ out.innerHTML = '';
+ (designers.designers || []).forEach(d => {
+ const row = document.createElement('div'); row.className = 'row-item';
+ row.innerHTML = `<div class="thumb" style="background-image:url('${d.avatar_url || ''}')"></div>
+ <div style="flex:1">
+ <div><strong>${d.display_name}</strong> · <a class="under" href="/designers/${d.slug}">/designers/${d.slug}</a></div>
+ <div class="meta-r">${d.level} · ${Number(d.points||0).toLocaleString()} pts · ${d.is_founding ? 'Founding' : ''} ${d.is_verified ? '· Verified' : ''}</div>
+ </div>`;
+ out.appendChild(row);
+ });
+ (patterns.patterns || []).forEach(p => {
+ const row = document.createElement('div'); row.className = 'row-item';
+ row.innerHTML = `<div class="thumb" style="background-image:url('${p.thumbnail_url || ''}')"></div>
+ <div style="flex:1">
+ <div><strong>${p.title}</strong> · <a class="under" href="/patterns/${p.slug}">/patterns/${p.slug}</a></div>
+ <div class="meta-r">by ${p.designer_name} · ${(p.style_tags||[]).slice(0,3).join(' · ')}</div>
+ </div>`;
+ out.appendChild(row);
+ });
+ } catch (err) {
+ logLine('latest fetch failed: ' + err.message, 'err');
+ }
+}
+
+let firstRun = true;
+async function tick() {
+ if (firstRun) { logLine('▶ status board live, polling every 4s', 'ok'); firstRun = false; }
+ await refreshStats();
+ await refreshLatest();
+ if (Math.random() < 0.25) await refreshChecks(); // re-smoke every ~16s
+}
+tick();
+refreshChecks();
+setInterval(tick, 4000);
+</script>
+</body>
+</html>
diff --git a/public/marketplace/storefront.html b/public/marketplace/storefront.html
new file mode 100644
index 0000000..98499cf
--- /dev/null
+++ b/public/marketplace/storefront.html
@@ -0,0 +1,90 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<title>Storefront · Wallco</title>
+<meta name="viewport" content="width=device-width,initial-scale=1" />
+<link rel="stylesheet" href="/marketplace/_layout.css" />
+<script src="/marketplace/_partials.js" defer></script>
+<style>
+ .hero-wide { padding: clamp(60px, 8vw, 140px) var(--mp-pad); background: linear-gradient(135deg,#1a1a1a 0%,#222 50%,#0d0d0d 100%); color: #fff; }
+ .hero-wide h1 { font-size: clamp(40px, 5vw, 80px); margin: 0; letter-spacing: -.02em; }
+ .hero-wide .meta-w { color: rgba(255,255,255,.7); }
+ .hero-wide .badges { margin: 12px 0; display:flex; gap:6px; flex-wrap:wrap; }
+ .hero-wide .mp-pill { background: rgba(255,255,255,.12); color:#fff; }
+ .hero-wide .mp-pill.gold { background: linear-gradient(135deg,#cda86e,#f2dca5); color: #2c2110; }
+</style>
+</head>
+<body>
+ <main id="root"><div class="mp-wrap"><p>Loading…</p></div></main>
+
+<script>
+const slug = location.pathname.split('/').filter(Boolean).pop();
+async function load() {
+ const j = await fetch('/api/marketplace/designers/' + encodeURIComponent(slug)).then(r => r.json());
+ const root = document.getElementById('root');
+ if (!j.designer) {
+ root.innerHTML = `<div class="mp-wrap"><h2>This designer hasn't published a storefront yet.</h2><a class="mp-btn" href="/designers">Browse designers</a></div>`;
+ return;
+ }
+ const d = j.designer;
+ root.innerHTML = `
+ <section class="hero-wide">
+ <div class="mp-wrap">
+ <div class="meta-w" style="margin-bottom: 10px">Wallco · Designer Storefront</div>
+ <h1>${d.display_name}</h1>
+ <div class="meta-w" style="margin-top: 8px; max-width: 720px; line-height: 1.6;">${d.bio || ''}</div>
+ <div class="badges">
+ <span class="mp-pill ${d.is_founding ? 'gold' : ''}">${d.level}</span>
+ ${d.is_verified ? '<span class="mp-pill verified">Verified</span>' : ''}
+ ${(j.badges||[]).map(b => `<span class="mp-pill">${b.icon||'★'} ${b.badge_name}</span>`).join('')}
+ </div>
+ <div style="margin-top: 18px; display:flex; gap:10px; flex-wrap:wrap;">
+ <a class="mp-btn" href="#patterns">Shop patterns</a>
+ <a class="mp-btn ghost" style="background:rgba(255,255,255,.08); color:#fff; border-color: rgba(255,255,255,.2);" href="/licensing/${slug}">License artwork</a>
+ <a class="mp-btn ghost" style="background:rgba(255,255,255,.08); color:#fff; border-color: rgba(255,255,255,.2);" href="#custom">Request custom design</a>
+ </div>
+ </div>
+ </section>
+ <main class="mp-wrap">
+ ${(j.collections||[]).length ? `<section class="mp-section">
+ <h2>Collections</h2>
+ <div class="mp-grid lg">
+ ${j.collections.map(c => `<div class="mp-card"><div class="img" style="background: linear-gradient(135deg,#eee,#ddd)"></div><div class="body"><div class="title">${c.title}</div></div></div>`).join('')}
+ </div>
+ </section>` : ''}
+ <section class="mp-section" id="patterns">
+ <h2>Patterns</h2>
+ <div class="mp-grid lg" id="pat"></div>
+ </section>
+ <section class="mp-section" id="custom">
+ <h2>Custom & licensing</h2>
+ <div class="mp-grid lg">
+ <div class="mp-card"><div class="body"><strong>Order samples</strong><div class="sub">Any pattern, $5 per sample, ships in 3–5 business days.</div><a class="mp-btn tiny" style="margin-top:8px" href="/patterns">Browse patterns</a></div></div>
+ <div class="mp-card"><div class="body"><strong>License artwork</strong><div class="sub">Non-exclusive or exclusive. Hospitality, commercial print, packaging.</div><a class="mp-btn tiny" style="margin-top:8px" href="/licensing/${slug}">Inquire</a></div></div>
+ <div class="mp-card"><div class="body"><strong>Custom commissions</strong><div class="sub">Custom recolors, scale changes, murals, or new artwork.</div><a class="mp-btn tiny" style="margin-top:8px" href="/licensing/${slug}">Request</a></div></div>
+ <div class="mp-card"><div class="body"><strong>Trade accounts</strong><div class="sub">Interior designers + architects — save to project boards, free samples, payment terms.</div><a class="mp-btn tiny" style="margin-top:8px" href="/marketplace/apply">Apply</a></div></div>
+ </div>
+ </section>
+ </main>`;
+
+ const pat = document.getElementById('pat');
+ (j.patterns || []).forEach(p => {
+ const a = document.createElement('a'); a.className='mp-card'; a.href=`/patterns/${p.slug}`;
+ a.innerHTML = `
+ <div class="img" style="background-image:url('${p.thumbnail_url || p.original_image_url}')"></div>
+ <div class="body">
+ <div class="title">${p.title}</div>
+ <div class="sub">${(p.style_tags||[]).slice(0,2).join(' · ')}</div>
+ <div class="row">
+ <a class="mp-btn tiny">Order sample</a>
+ <a class="mp-btn tiny ghost">License</a>
+ </div>
+ </div>`;
+ pat.appendChild(a);
+ });
+}
+load();
+</script>
+</body>
+</html>
diff --git a/scripts/seed-marketplace.js b/scripts/seed-marketplace.js
new file mode 100644
index 0000000..317c46e
--- /dev/null
+++ b/scripts/seed-marketplace.js
@@ -0,0 +1,277 @@
+#!/usr/bin/env node
+/**
+ * Seed marketplace with fictional designers + 24 patterns sourced from existing
+ * Wallco internal designs (spoon_all_designs) — NEVER from third-party platforms.
+ *
+ * Idempotent: re-running won't duplicate (uses unique slugs + ON CONFLICT DO NOTHING).
+ */
+const path = require('path');
+process.chdir(path.join(__dirname, '..'));
+
+const { q, one, many } = require('../src/marketplace/db');
+const { slugify, shortToken } = require('../src/marketplace/util');
+const gam = require('../src/marketplace/gamification');
+const commissions = require('../src/marketplace/commissions');
+
+const FICTIONAL_DESIGNERS = [
+ {
+ display_name: 'Astrid Mauve',
+ studio_name: 'Astrid Mauve Studio',
+ slug: 'astrid-mauve',
+ bio: 'Painterly florals and botanical layering inspired by 1920s European wallpaper archives. Trained at RISD, based in Brooklyn.',
+ location: 'Brooklyn, NY',
+ style_tags: ['Botanical', 'Painterly', 'Romantic', 'Quiet Luxury'],
+ avatar_url: '/img/placeholder-designer-1.svg',
+ is_founding: true, is_verified: true,
+ commission_rate: 20,
+ badges: ['FOUNDING_DESIGNER', 'VERIFIED_DESIGNER', 'NEUTRAL_HERO', 'HISTORIC_REVIVALIST'],
+ },
+ {
+ display_name: 'Rene Okafor',
+ studio_name: 'Okafor Pattern Lab',
+ slug: 'rene-okafor',
+ bio: 'Bold geometric repeats with West-African textile DNA. Specializes in hospitality and large-scale commercial wallcovering.',
+ location: 'Lagos / London',
+ style_tags: ['Geometric', 'Maximalist', 'Hospitality', 'Contract'],
+ avatar_url: '/img/placeholder-designer-2.svg',
+ is_founding: true, is_verified: true,
+ commission_rate: 20,
+ badges: ['FOUNDING_DESIGNER', 'VERIFIED_DESIGNER', 'HOSPITALITY_READY', 'MAXIMALIST_MASTER'],
+ },
+ {
+ display_name: 'Maya Linsbeck',
+ studio_name: 'Linsbeck',
+ slug: 'maya-linsbeck',
+ bio: 'Minimal grasscloth-inspired textures and tone-on-tone naturals. Each pattern is hand-drawn before going to repeat.',
+ location: 'Stockholm, SE',
+ style_tags: ['Minimal', 'Natural', 'Grasscloth', 'Quiet Luxury'],
+ avatar_url: '/img/placeholder-designer-3.svg',
+ is_founding: true,
+ commission_rate: 18,
+ badges: ['FOUNDING_DESIGNER', 'GRASSCLOTH_VISIONARY', 'COLOR_GENIUS'],
+ },
+ {
+ display_name: 'Hugo Reyes',
+ studio_name: 'Reyes & Co',
+ slug: 'hugo-reyes',
+ bio: 'Mid-century-modern motifs translated for contemporary boutique hotels. Heavy on terracotta, ochre, and ink-blue palettes.',
+ location: 'Mexico City, MX',
+ style_tags: ['Mid-Century', 'Boutique Hotel', 'Bold Color'],
+ avatar_url: '/img/placeholder-designer-4.svg',
+ is_founding: false, is_verified: true,
+ commission_rate: 17,
+ badges: ['VERIFIED_DESIGNER', 'BOUTIQUE_HOTEL_FAVORITE'],
+ },
+ {
+ display_name: 'Sasha Nordheim',
+ studio_name: 'Nordheim Atelier',
+ slug: 'sasha-nordheim',
+ bio: 'Hand-painted toiles reimagined for kids’ rooms and powder rooms. Whimsical without being childish.',
+ location: 'Copenhagen, DK',
+ style_tags: ['Toile', 'Children', 'Whimsical', 'Painterly'],
+ avatar_url: '/img/placeholder-designer-5.svg',
+ commission_rate: 15,
+ badges: ['KID_ROOM_FAVORITE'],
+ },
+];
+
+const PATTERN_TEMPLATES = [
+ { title: 'Wildflower Reverie', style: ['Botanical','Romantic'], room: ['Powder Room','Bedroom'], color: ['Warm Neutral','Olive'] },
+ { title: 'Trellis Echo', style: ['Geometric','Traditional'], room: ['Dining Room','Hallway'], color: ['Plaster','Charcoal'] },
+ { title: 'Marsh Linen', style: ['Natural','Minimal'], room: ['Living Room','Office'], color: ['Linen','Olive'] },
+ { title: 'Coast Bluff', style: ['Mid-Century','Coastal'], room: ['Living Room','Hospitality'], color: ['Ink Blue','Sand'] },
+ { title: 'Garden Toile', style: ['Toile','Romantic'], room: ['Bedroom','Nursery'], color: ['Rose','Ivory'] },
+ { title: 'Heritage Vine', style: ['Botanical','Historic'], room: ['Powder Room','Library'], color: ['Forest','Cream'] },
+ { title: 'Klein Stripe', style: ['Geometric','Modern'], room: ['Hallway','Office'], color: ['Cobalt','Bone'] },
+ { title: 'Ochre Trellis', style: ['Geometric','Mid-Century'], room: ['Dining Room','Hotel Lobby'], color: ['Ochre','Plaster'] },
+ { title: 'Quiet Garden', style: ['Botanical','Quiet Luxury'], room: ['Bedroom','Spa'], color: ['Moss','Bone'] },
+ { title: 'Linen Shadow', style: ['Texture','Natural'], room: ['Living Room','Office'], color: ['Linen','Smoke'] },
+ { title: 'Brooklyn Bloom', style: ['Botanical','Maximalist'], room: ['Powder Room','Restaurant'], color: ['Plum','Cream'] },
+ { title: 'Atlas Tile', style: ['Geometric','Hospitality'], room: ['Hotel Lobby','Restaurant'], color: ['Charcoal','Brass'] },
+ { title: 'Hand-Drawn Vines', style: ['Botanical','Hand-Drawn'], room: ['Bedroom'], color: ['Sage','Cream'] },
+ { title: 'Studio Linen', style: ['Texture','Minimal'], room: ['Office','Living Room'], color: ['Bone','Linen'] },
+ { title: 'Casa Cobalt', style: ['Mid-Century','Bold'], room: ['Hospitality'], color: ['Cobalt','Terracotta'] },
+ { title: 'Mañana Garden', style: ['Botanical','Mid-Century'], room: ['Restaurant'], color: ['Terracotta','Ochre'] },
+ { title: 'Toile Petite', style: ['Toile','Children'], room: ['Nursery'], color: ['Sky','Cream'] },
+ { title: 'Forest Mouse', style: ['Toile','Whimsical'], room: ['Nursery'], color: ['Forest','Cream'] },
+ { title: 'Powder Plaster', style: ['Minimal','Quiet Luxury'], room: ['Powder Room'], color: ['Plaster','Sand'] },
+ { title: 'Bone Garden', style: ['Botanical','Quiet Luxury'], room: ['Bedroom'], color: ['Bone','Sage'] },
+ { title: 'Hotel Bluff', style: ['Hospitality','Bold'], room: ['Hotel Lobby'], color: ['Ink Blue','Brass'] },
+ { title: 'Brass Tile', style: ['Geometric','Hospitality'], room: ['Restaurant'], color: ['Brass','Charcoal'] },
+ { title: 'Linen Whisper', style: ['Texture','Natural'], room: ['Bedroom','Living Room'], color: ['Linen','Bone'] },
+ { title: 'Climbing Roses', style: ['Toile','Romantic'], room: ['Bedroom'], color: ['Rose','Sage'] },
+];
+
+const COLORWAY_PALETTES = [
+ { name: 'Original', hex: ['#2C2A2D','#C9B996','#7A6A5E','#F2EAD8'] },
+ { name: 'Warm Clay', hex: ['#B46A55','#E8C7A9','#6B4B3E','#F4EFE8'] },
+ { name: 'Moss & Linen', hex: ['#69785A','#D7D0B8','#2F3A2D','#F7F4EA'] },
+ { name: 'Ink Blue', hex: ['#1F2E46','#7E9AB8','#E9EDF2','#111827'] },
+ { name: 'Rose Plaster', hex: ['#C98986','#F0D4CF','#80514F','#FFF8F5'] },
+ { name: 'Gold Smoke', hex: ['#BFA76A','#E6DDC5','#56514A','#151515'] },
+];
+
+const UNCLAIMED_PAGES = [
+ // Generic-sounding stub names ONLY. Do NOT include real designer names from public platforms.
+ { public_name: 'Pattern Studio One', slug: 'pattern-studio-one' },
+ { public_name: 'Atelier Eleven', slug: 'atelier-eleven' },
+ { public_name: 'Marble House Design', slug: 'marble-house-design' },
+ { public_name: 'North Studio', slug: 'north-studio' },
+ { public_name: 'Coast & Cane', slug: 'coast-and-cane' },
+];
+
+async function pickInternalThumb(idx) {
+ // Borrow a real Wallco-owned design image (spoon_all_designs.local_path).
+ // We control all of those; Steve has rights.
+ const rows = await many(`
+ SELECT id, local_path FROM spoon_all_designs
+ WHERE local_path IS NOT NULL AND local_path <> ''
+ AND COALESCE(user_removed, false) = false
+ ORDER BY id ASC LIMIT 200`).catch(() => []);
+ if (!rows || !rows.length) return null;
+ const pick = rows[idx % rows.length];
+ return { id: pick.id, image_url: '/designs/img/' + path.basename(pick.local_path) };
+}
+
+async function main() {
+ console.log('▶ Seed marketplace (fictional only)');
+ for (const d of FICTIONAL_DESIGNERS) {
+ // user
+ const email = d.slug + '@wallco-demo.invalid';
+ const u = await one(
+ `INSERT INTO mp_users (email, display_name, role)
+ VALUES ($1,$2,'designer')
+ ON CONFLICT (email) DO UPDATE SET display_name=EXCLUDED.display_name
+ RETURNING id`, [email, d.display_name]);
+
+ const existing = await one(`SELECT id, points FROM mp_designer_profiles WHERE slug=$1`, [d.slug]);
+ let profile;
+ if (existing) {
+ profile = existing;
+ } else {
+ profile = await one(
+ `INSERT INTO mp_designer_profiles
+ (user_id, display_name, slug, studio_name, bio, location, style_tags, avatar_url,
+ is_founding, is_verified, status, commission_rate, rights_agreement_at, commission_agreement_at, points, level)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'approved',$11,now(),now(),0,'New Creator')
+ RETURNING id, points`,
+ [u.id, d.display_name, d.slug, d.studio_name, d.bio, d.location, d.style_tags, d.avatar_url,
+ !!d.is_founding, !!d.is_verified, d.commission_rate || 15]);
+ console.log(' + designer', d.slug);
+ }
+ for (const b of (d.badges || [])) {
+ try { await gam.awardBadge(profile.id, b); } catch (_) {}
+ }
+ // award a "PROFILE_COMPLETED" once
+ const seen = await one(`SELECT id FROM mp_points_ledger WHERE designer_id=$1 AND action='PROFILE_COMPLETED'`, [profile.id]);
+ if (!seen) { try { await gam.awardPoints(profile.id, 'PROFILE_COMPLETED'); } catch (_) {} }
+ }
+
+ // Patterns — distribute 24 across the 5 designers
+ let imgIdx = 0;
+ for (let i = 0; i < PATTERN_TEMPLATES.length; i++) {
+ const t = PATTERN_TEMPLATES[i];
+ const designerSlug = FICTIONAL_DESIGNERS[i % FICTIONAL_DESIGNERS.length].slug;
+ const d = await one(`SELECT id, is_founding FROM mp_designer_profiles WHERE slug=$1`, [designerSlug]);
+ if (!d) continue;
+ const slug = slugify(t.title) + '-' + designerSlug;
+ const existing = await one(`SELECT id FROM mp_patterns WHERE slug=$1`, [slug]);
+ if (existing) continue;
+
+ const thumb = await pickInternalThumb(imgIdx++);
+ const imgUrl = thumb ? thumb.image_url : '/img/placeholder-pattern.svg';
+ const externalId = thumb ? thumb.id : null;
+
+ const pat = await one(
+ `INSERT INTO mp_patterns
+ (designer_id, title, slug, description, original_image_url, thumbnail_url,
+ repeat_type, motif_tags, style_tags, room_tags, color_tags, mood_tags,
+ commercial_suitable, hospitality_ready, rights_confirmed, rights_confirmed_at,
+ rights_warranty_text, source_type, status, featured, base_price, sample_price, license_price, external_design_id)
+ VALUES ($1,$2,$3,$4,$5,$5,$6,$7,$8,$9,$10,$11,$12,$13,true,now(),$14,'internal_collection','approved',$15,$16,$17,$18,$19)
+ RETURNING id`,
+ [d.id, t.title, slug,
+ `${t.title} — a ${t.style.join(', ').toLowerCase()} repeat suited to ${t.room.join(', ').toLowerCase()}.`,
+ imgUrl, 'straight',
+ t.style.slice(), t.style.slice(), t.room.slice(), t.color.slice(), ['Quiet Luxury','Editorial'],
+ t.style.includes('Hospitality') || t.room.includes('Hotel Lobby') || t.room.includes('Restaurant'),
+ t.style.includes('Hospitality'),
+ 'Wallco demo seed — all artwork sourced from Wallco-owned internal collection (spoon_all_designs).',
+ i % 5 === 0, // featured every 5th
+ 248.00, 5.00, 1800.00,
+ externalId]);
+
+ // Add 3 colorways per pattern
+ for (let c = 0; c < 3; c++) {
+ const p = COLORWAY_PALETTES[(i + c) % COLORWAY_PALETTES.length];
+ await q(
+ `INSERT INTO mp_pattern_colorways (pattern_id, name, hex_palette, is_primary, ai_generated, display_order)
+ VALUES ($1,$2,$3::jsonb,$4,$5,$6)`,
+ [pat.id, p.name, JSON.stringify(p.hex), c === 0, c > 0, c]);
+ }
+ try { await gam.awardPoints(d.id, 'PATTERN_UPLOADED', { patternId: pat.id }); } catch (_) {}
+ try { await gam.awardPoints(d.id, 'PATTERN_APPROVED', { patternId: pat.id }); } catch (_) {}
+ }
+
+ // Collections — 8 total, one or two per designer
+ const COLLECTIONS = [
+ { designerSlug: 'astrid-mauve', title: '2026 Quiet Luxury Florals', desc: 'A 12-pattern story of plaster grounds and painterly botanicals for boutique hotels.' },
+ { designerSlug: 'astrid-mauve', title: 'Heritage Garden Library', desc: 'Archival florals reimagined for trade specification.' },
+ { designerSlug: 'rene-okafor', title: 'Lagos Contract', desc: 'Type II wallcovering for hospitality, scrubbable, fire-rated.' },
+ { designerSlug: 'rene-okafor', title: 'Maximal Geometry', desc: 'Bold mid-scale geometrics on saturated grounds.' },
+ { designerSlug: 'maya-linsbeck',title: 'Linen + Lichen', desc: 'Quiet naturals — bone, sage, olive, plaster.' },
+ { designerSlug: 'hugo-reyes', title: 'Hotel Casa', desc: 'Mid-century forms for restaurants and boutique lobbies.' },
+ { designerSlug: 'sasha-nordheim', title: 'Toile Petite', desc: 'Toile reimagined for nurseries and powder rooms.' },
+ { designerSlug: 'sasha-nordheim', title: 'Story Garden', desc: 'Whimsical botanical scenes for kids who hate cartoons.' },
+ ];
+ for (const c of COLLECTIONS) {
+ const d = await one(`SELECT id FROM mp_designer_profiles WHERE slug=$1`, [c.designerSlug]);
+ if (!d) continue;
+ const slug = slugify(c.title) + '-' + c.designerSlug;
+ const existing = await one(`SELECT id FROM mp_collections WHERE slug=$1`, [slug]);
+ if (existing) continue;
+ await q(`INSERT INTO mp_collections (designer_id, title, slug, description, status, featured)
+ VALUES ($1,$2,$3,$4,'published',$5)`,
+ [d.id, c.title, slug, c.desc, true]);
+ }
+
+ // Unclaimed pages
+ for (const p of UNCLAIMED_PAGES) {
+ await q(
+ `INSERT INTO mp_designer_claim_pages (public_name, slug, source_notes)
+ VALUES ($1,$2,$3) ON CONFLICT (slug) DO NOTHING`,
+ [p.public_name, p.slug, 'demo seed — placeholder for outreach pipeline']);
+ }
+
+ // Demo orders + commission entries
+ const demoUser = await one(
+ `INSERT INTO mp_users (email, display_name, role) VALUES ('demo-buyer@wallco-demo.invalid','Demo Trade Buyer','trade')
+ ON CONFLICT (email) DO UPDATE SET display_name=EXCLUDED.display_name RETURNING id`);
+ const samplePatterns = await many(`SELECT id, designer_id FROM mp_patterns WHERE status='approved' ORDER BY id LIMIT 6`);
+ for (const p of samplePatterns) {
+ const ord = await one(`INSERT INTO mp_orders (user_id, order_type, subtotal, total, status, fulfilled_at)
+ VALUES ($1,'wallpaper',496,496,'paid',now()) RETURNING id`, [demoUser.id]);
+ const lineTotal = 496;
+ const designer = await one(`SELECT id, commission_rate, is_founding FROM mp_designer_profiles WHERE id=$1`, [p.designer_id]);
+ const rate = designer.commission_rate;
+ const built = commissions.buildLedgerEntry({ line_total: lineTotal, commission_rate: rate });
+ const item = await one(
+ `INSERT INTO mp_order_items (order_id, pattern_id, designer_id, product_type, quantity, unit_price, line_total, commission_rate, commission_amount, commission_basis)
+ VALUES ($1,$2,$3,'wallpaper',2,248,$4,$5,$6,$7) RETURNING id`,
+ [ord.id, p.id, designer.id, lineTotal, rate, built.amount, built.basis]);
+ await q(
+ `INSERT INTO mp_commission_ledger
+ (designer_id, order_id, order_item_id, pattern_id, entry_type, basis_amount, commission_rate, commission_amount, status, payable_at)
+ VALUES ($1,$2,$3,$4,'wallpaper_sale',$5,$6,$7,'payable', now() + interval '30 days')`,
+ [designer.id, ord.id, item.id, p.id, built.basis, built.rate, built.amount]);
+ try { await gam.awardPoints(designer.id, 'FULL_ORDER', { patternId: p.id, orderId: ord.id }); } catch (_) {}
+ }
+
+ console.log('✓ Seed complete');
+ process.exit(0);
+}
+
+main().catch((err) => {
+ console.error('seed failed:', err);
+ process.exit(1);
+});
diff --git a/server.js b/server.js
index c6721b8..b713aa7 100644
--- a/server.js
+++ b/server.js
@@ -106,6 +106,9 @@ try {
// ── Admin layer: vendor browse + aesthetic search + inspiration pool (NO-LEAK — admin only)
try { require('./src/admin').mount(app); console.log(' Admin layer mounted'); } catch (e) { console.error('Admin mount failed:', e.message); }
+// ── Marketplace layer: designer-powered marketplace (Patternbank × Material Bank × Spoonflower)
+try { require('./src/marketplace').mount(app); } catch (e) { console.error('Marketplace mount failed:', e.message); }
+
// ── Cache-Control: no-store on HTML (per feedback_cloudflare_html_caching.md)
app.use((req, res, next) => {
if (req.accepts('html') && !req.path.startsWith('/designs/img')) {
diff --git a/sql/100_designer_marketplace.sql b/sql/100_designer_marketplace.sql
new file mode 100644
index 0000000..92c01dc
--- /dev/null
+++ b/sql/100_designer_marketplace.sql
@@ -0,0 +1,348 @@
+-- Wallco Designer Marketplace schema
+-- 2026-05-13 — additive only, all tables prefixed mp_ (marketplace) to avoid collisions with wallco_* and spoon_*
+-- Lives in dw_unified DB alongside wallco_* tables. Safe to re-run (IF NOT EXISTS everywhere).
+
+BEGIN;
+
+-- ── 1. Users (lightweight; reuses wallco_trade_users where possible)
+CREATE TABLE IF NOT EXISTS mp_users (
+ id BIGSERIAL PRIMARY KEY,
+ email TEXT UNIQUE NOT NULL,
+ display_name TEXT,
+ role TEXT NOT NULL DEFAULT 'buyer' CHECK (role IN ('buyer','designer','trade','admin')),
+ trade_user_id BIGINT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS mp_users_email_idx ON mp_users(email);
+CREATE INDEX IF NOT EXISTS mp_users_role_idx ON mp_users(role);
+
+-- ── 2. Designer profiles
+CREATE TABLE IF NOT EXISTS mp_designer_profiles (
+ id BIGSERIAL PRIMARY KEY,
+ user_id BIGINT REFERENCES mp_users(id) ON DELETE SET NULL,
+ display_name TEXT NOT NULL,
+ slug TEXT UNIQUE NOT NULL,
+ studio_name TEXT,
+ bio TEXT,
+ website_url TEXT,
+ instagram_url TEXT,
+ location TEXT,
+ avatar_url TEXT,
+ cover_url TEXT,
+ style_tags TEXT[] DEFAULT ARRAY[]::TEXT[],
+ is_claimed BOOLEAN NOT NULL DEFAULT false,
+ is_verified BOOLEAN NOT NULL DEFAULT false,
+ is_founding BOOLEAN NOT NULL DEFAULT false,
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected','suspended')),
+ commission_rate NUMERIC(5,2) NOT NULL DEFAULT 15.00,
+ license_rate_nonexclusive NUMERIC(5,2) NOT NULL DEFAULT 60.00,
+ license_rate_exclusive NUMERIC(5,2) NOT NULL DEFAULT 70.00,
+ points INTEGER NOT NULL DEFAULT 0,
+ level TEXT NOT NULL DEFAULT 'New Creator',
+ payout_method TEXT,
+ payout_details JSONB DEFAULT '{}'::JSONB,
+ rights_agreement_at TIMESTAMPTZ,
+ commission_agreement_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS mp_designer_profiles_slug_idx ON mp_designer_profiles(slug);
+CREATE INDEX IF NOT EXISTS mp_designer_profiles_status_idx ON mp_designer_profiles(status);
+CREATE INDEX IF NOT EXISTS mp_designer_profiles_points_idx ON mp_designer_profiles(points DESC);
+
+-- ── 3. Unclaimed designer claim pages (legal-safe stubs)
+CREATE TABLE IF NOT EXISTS mp_designer_claim_pages (
+ id BIGSERIAL PRIMARY KEY,
+ public_name TEXT NOT NULL,
+ slug TEXT UNIQUE NOT NULL,
+ source_notes TEXT,
+ outreach_status TEXT NOT NULL DEFAULT 'unclaimed' CHECK (outreach_status IN ('unclaimed','invited','contacted','claimed','declined','removed')),
+ claimed_by_designer_id BIGINT REFERENCES mp_designer_profiles(id) ON DELETE SET NULL,
+ claimed_at TIMESTAMPTZ,
+ disclaimer TEXT NOT NULL DEFAULT 'This is an unclaimed public designer profile. It is not affiliated with or endorsed by the designer. Are you this designer? Claim this page to add your official portfolio, storefront, and commission settings.',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS mp_claim_slug_idx ON mp_designer_claim_pages(slug);
+
+-- ── 4. Marketplace patterns (separate from spoon_all_designs — these are designer-uploaded or claimed)
+CREATE TABLE IF NOT EXISTS mp_patterns (
+ id BIGSERIAL PRIMARY KEY,
+ designer_id BIGINT NOT NULL REFERENCES mp_designer_profiles(id) ON DELETE CASCADE,
+ title TEXT NOT NULL,
+ slug TEXT UNIQUE NOT NULL,
+ description TEXT,
+ long_description TEXT,
+ original_image_url TEXT NOT NULL,
+ thumbnail_url TEXT,
+ repeat_type TEXT,
+ repeat_width_in NUMERIC(8,2),
+ repeat_height_in NUMERIC(8,2),
+ scale_notes TEXT,
+ motif_tags TEXT[] DEFAULT ARRAY[]::TEXT[],
+ style_tags TEXT[] DEFAULT ARRAY[]::TEXT[],
+ room_tags TEXT[] DEFAULT ARRAY[]::TEXT[],
+ color_tags TEXT[] DEFAULT ARRAY[]::TEXT[],
+ mood_tags TEXT[] DEFAULT ARRAY[]::TEXT[],
+ commercial_suitable BOOLEAN DEFAULT false,
+ hospitality_ready BOOLEAN DEFAULT false,
+ fire_rating TEXT,
+ source_type TEXT NOT NULL DEFAULT 'designer_upload' CHECK (source_type IN ('designer_upload','wallco_generated','licensed','public_domain','internal_collection')),
+ ai_generated BOOLEAN NOT NULL DEFAULT false,
+ rights_confirmed BOOLEAN NOT NULL DEFAULT false,
+ rights_confirmed_at TIMESTAMPTZ,
+ rights_warranty_text TEXT,
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected','archived','flagged')),
+ rejection_reason TEXT,
+ featured BOOLEAN NOT NULL DEFAULT false,
+ view_count INTEGER NOT NULL DEFAULT 0,
+ save_count INTEGER NOT NULL DEFAULT 0,
+ sample_count INTEGER NOT NULL DEFAULT 0,
+ sale_count INTEGER NOT NULL DEFAULT 0,
+ base_price NUMERIC(10,2),
+ sample_price NUMERIC(10,2),
+ license_price NUMERIC(10,2),
+ external_design_id BIGINT, -- optional link to spoon_all_designs.id when re-listing internal art
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS mp_patterns_designer_idx ON mp_patterns(designer_id);
+CREATE INDEX IF NOT EXISTS mp_patterns_status_idx ON mp_patterns(status);
+CREATE INDEX IF NOT EXISTS mp_patterns_slug_idx ON mp_patterns(slug);
+CREATE INDEX IF NOT EXISTS mp_patterns_featured_idx ON mp_patterns(featured) WHERE featured = true;
+
+-- ── 5. Colorway variants per pattern
+CREATE TABLE IF NOT EXISTS mp_pattern_colorways (
+ id BIGSERIAL PRIMARY KEY,
+ pattern_id BIGINT NOT NULL REFERENCES mp_patterns(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ image_url TEXT,
+ hex_palette JSONB DEFAULT '[]'::JSONB,
+ is_primary BOOLEAN NOT NULL DEFAULT false,
+ ai_generated BOOLEAN NOT NULL DEFAULT false,
+ display_order INTEGER NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS mp_colorways_pattern_idx ON mp_pattern_colorways(pattern_id);
+
+-- ── 6. Collections
+CREATE TABLE IF NOT EXISTS mp_collections (
+ id BIGSERIAL PRIMARY KEY,
+ designer_id BIGINT NOT NULL REFERENCES mp_designer_profiles(id) ON DELETE CASCADE,
+ title TEXT NOT NULL,
+ slug TEXT UNIQUE NOT NULL,
+ description TEXT,
+ cover_image_url TEXT,
+ status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','published','archived')),
+ featured BOOLEAN NOT NULL DEFAULT false,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS mp_collections_designer_idx ON mp_collections(designer_id);
+
+CREATE TABLE IF NOT EXISTS mp_collection_patterns (
+ collection_id BIGINT NOT NULL REFERENCES mp_collections(id) ON DELETE CASCADE,
+ pattern_id BIGINT NOT NULL REFERENCES mp_patterns(id) ON DELETE CASCADE,
+ display_order INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY (collection_id, pattern_id)
+);
+
+-- ── 7. Badges
+CREATE TABLE IF NOT EXISTS mp_designer_badges (
+ id BIGSERIAL PRIMARY KEY,
+ designer_id BIGINT NOT NULL REFERENCES mp_designer_profiles(id) ON DELETE CASCADE,
+ badge_key TEXT NOT NULL,
+ badge_name TEXT NOT NULL,
+ badge_description TEXT,
+ icon TEXT,
+ awarded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE (designer_id, badge_key)
+);
+CREATE INDEX IF NOT EXISTS mp_badges_designer_idx ON mp_designer_badges(designer_id);
+
+-- ── 8. Points ledger (every earning event)
+CREATE TABLE IF NOT EXISTS mp_points_ledger (
+ id BIGSERIAL PRIMARY KEY,
+ designer_id BIGINT NOT NULL REFERENCES mp_designer_profiles(id) ON DELETE CASCADE,
+ action TEXT NOT NULL,
+ points INTEGER NOT NULL,
+ related_pattern_id BIGINT,
+ related_order_id BIGINT,
+ related_inquiry_id BIGINT,
+ notes TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS mp_points_designer_idx ON mp_points_ledger(designer_id);
+CREATE INDEX IF NOT EXISTS mp_points_created_idx ON mp_points_ledger(created_at DESC);
+
+-- ── 9. Orders (lightweight — heavy commerce lives in Shopify; this is the attribution ledger)
+CREATE TABLE IF NOT EXISTS mp_orders (
+ id BIGSERIAL PRIMARY KEY,
+ user_id BIGINT REFERENCES mp_users(id) ON DELETE SET NULL,
+ external_shopify_order_id TEXT,
+ order_type TEXT NOT NULL CHECK (order_type IN ('wallpaper','sample','license','custom','recolor')),
+ subtotal NUMERIC(12,2) NOT NULL DEFAULT 0,
+ total NUMERIC(12,2) NOT NULL DEFAULT 0,
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','paid','fulfilled','refunded','cancelled')),
+ fulfilled_at TIMESTAMPTZ,
+ referral_designer_id BIGINT REFERENCES mp_designer_profiles(id) ON DELETE SET NULL,
+ referral_source TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS mp_orders_user_idx ON mp_orders(user_id);
+CREATE INDEX IF NOT EXISTS mp_orders_shopify_idx ON mp_orders(external_shopify_order_id);
+
+CREATE TABLE IF NOT EXISTS mp_order_items (
+ id BIGSERIAL PRIMARY KEY,
+ order_id BIGINT NOT NULL REFERENCES mp_orders(id) ON DELETE CASCADE,
+ pattern_id BIGINT NOT NULL REFERENCES mp_patterns(id) ON DELETE RESTRICT,
+ designer_id BIGINT NOT NULL REFERENCES mp_designer_profiles(id) ON DELETE RESTRICT,
+ product_type TEXT NOT NULL CHECK (product_type IN ('wallpaper','sample','license','custom','recolor')),
+ quantity NUMERIC(12,2) NOT NULL DEFAULT 1,
+ unit_price NUMERIC(12,2) NOT NULL DEFAULT 0,
+ line_total NUMERIC(12,2) NOT NULL DEFAULT 0,
+ discount_amount NUMERIC(12,2) NOT NULL DEFAULT 0,
+ refund_amount NUMERIC(12,2) NOT NULL DEFAULT 0,
+ tax_amount NUMERIC(12,2) NOT NULL DEFAULT 0,
+ shipping_amount NUMERIC(12,2) NOT NULL DEFAULT 0,
+ sample_credit_amount NUMERIC(12,2) NOT NULL DEFAULT 0,
+ commission_rate NUMERIC(5,2) NOT NULL DEFAULT 15.00,
+ commission_amount NUMERIC(12,2) NOT NULL DEFAULT 0,
+ commission_basis NUMERIC(12,2) NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS mp_order_items_order_idx ON mp_order_items(order_id);
+CREATE INDEX IF NOT EXISTS mp_order_items_designer_idx ON mp_order_items(designer_id);
+CREATE INDEX IF NOT EXISTS mp_order_items_pattern_idx ON mp_order_items(pattern_id);
+
+-- ── 10. Commission ledger (every earning event for designer)
+CREATE TABLE IF NOT EXISTS mp_commission_ledger (
+ id BIGSERIAL PRIMARY KEY,
+ designer_id BIGINT NOT NULL REFERENCES mp_designer_profiles(id) ON DELETE CASCADE,
+ order_id BIGINT REFERENCES mp_orders(id) ON DELETE SET NULL,
+ order_item_id BIGINT REFERENCES mp_order_items(id) ON DELETE SET NULL,
+ pattern_id BIGINT REFERENCES mp_patterns(id) ON DELETE SET NULL,
+ entry_type TEXT NOT NULL CHECK (entry_type IN ('wallpaper_sale','sample_credit','custom_recolor','license_sale','exclusive_license','referral_bonus','manual_adjustment','refund_reversal')),
+ basis_amount NUMERIC(12,2) NOT NULL,
+ commission_rate NUMERIC(5,2) NOT NULL,
+ commission_amount NUMERIC(12,2) NOT NULL,
+ currency TEXT NOT NULL DEFAULT 'USD',
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','payable','paid','reversed','disputed')),
+ payable_at TIMESTAMPTZ,
+ paid_at TIMESTAMPTZ,
+ payout_batch_id BIGINT,
+ notes TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS mp_commission_designer_idx ON mp_commission_ledger(designer_id);
+CREATE INDEX IF NOT EXISTS mp_commission_status_idx ON mp_commission_ledger(status);
+CREATE INDEX IF NOT EXISTS mp_commission_payable_idx ON mp_commission_ledger(payable_at) WHERE status IN ('pending','approved','payable');
+
+CREATE TABLE IF NOT EXISTS mp_payout_batches (
+ id BIGSERIAL PRIMARY KEY,
+ batch_label TEXT NOT NULL,
+ total_amount NUMERIC(12,2) NOT NULL DEFAULT 0,
+ designer_count INTEGER NOT NULL DEFAULT 0,
+ status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','exported','processing','paid','failed')),
+ csv_export_path TEXT,
+ notes TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ paid_at TIMESTAMPTZ
+);
+
+-- ── 11. Licensing inquiries
+CREATE TABLE IF NOT EXISTS mp_licensing_inquiries (
+ id BIGSERIAL PRIMARY KEY,
+ pattern_id BIGINT REFERENCES mp_patterns(id) ON DELETE SET NULL,
+ designer_id BIGINT REFERENCES mp_designer_profiles(id) ON DELETE SET NULL,
+ buyer_user_id BIGINT REFERENCES mp_users(id) ON DELETE SET NULL,
+ buyer_name TEXT NOT NULL,
+ buyer_email TEXT NOT NULL,
+ buyer_company TEXT,
+ license_type TEXT NOT NULL CHECK (license_type IN ('non_exclusive','exclusive','custom','editorial','print_on_demand')),
+ usage_description TEXT,
+ territory TEXT,
+ duration_months INTEGER,
+ budget_range TEXT,
+ status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new','reviewing','quoted','accepted','rejected','contracted','closed')),
+ admin_notes TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS mp_licensing_pattern_idx ON mp_licensing_inquiries(pattern_id);
+CREATE INDEX IF NOT EXISTS mp_licensing_status_idx ON mp_licensing_inquiries(status);
+
+-- ── 12. Trade buyer project boards
+CREATE TABLE IF NOT EXISTS mp_projects (
+ id BIGSERIAL PRIMARY KEY,
+ user_id BIGINT REFERENCES mp_users(id) ON DELETE CASCADE,
+ title TEXT NOT NULL,
+ client_name TEXT,
+ room_type TEXT,
+ notes TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS mp_projects_user_idx ON mp_projects(user_id);
+
+CREATE TABLE IF NOT EXISTS mp_project_saves (
+ id BIGSERIAL PRIMARY KEY,
+ project_id BIGINT NOT NULL REFERENCES mp_projects(id) ON DELETE CASCADE,
+ pattern_id BIGINT NOT NULL REFERENCES mp_patterns(id) ON DELETE CASCADE,
+ colorway_id BIGINT REFERENCES mp_pattern_colorways(id) ON DELETE SET NULL,
+ notes TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE (project_id, pattern_id, colorway_id)
+);
+
+-- ── 13. Followers
+CREATE TABLE IF NOT EXISTS mp_designer_followers (
+ id BIGSERIAL PRIMARY KEY,
+ designer_id BIGINT NOT NULL REFERENCES mp_designer_profiles(id) ON DELETE CASCADE,
+ follower_user_id BIGINT NOT NULL REFERENCES mp_users(id) ON DELETE CASCADE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE (designer_id, follower_user_id)
+);
+
+-- ── 14. Moderation events (audit trail)
+CREATE TABLE IF NOT EXISTS mp_moderation_events (
+ id BIGSERIAL PRIMARY KEY,
+ target_type TEXT NOT NULL CHECK (target_type IN ('designer','pattern','collection','claim','inquiry')),
+ target_id BIGINT NOT NULL,
+ action TEXT NOT NULL,
+ admin_user_id BIGINT,
+ reason TEXT,
+ metadata JSONB DEFAULT '{}'::JSONB,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS mp_moderation_target_idx ON mp_moderation_events(target_type, target_id);
+
+-- ── 15. Takedown requests
+CREATE TABLE IF NOT EXISTS mp_takedown_requests (
+ id BIGSERIAL PRIMARY KEY,
+ pattern_id BIGINT REFERENCES mp_patterns(id) ON DELETE SET NULL,
+ claimant_name TEXT NOT NULL,
+ claimant_email TEXT NOT NULL,
+ claim_description TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','reviewing','removed','rejected','closed')),
+ resolution_notes TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ resolved_at TIMESTAMPTZ
+);
+
+-- Updated_at triggers (designer + pattern + collection)
+CREATE OR REPLACE FUNCTION mp_touch_updated_at() RETURNS TRIGGER AS $$
+BEGIN NEW.updated_at = now(); RETURN NEW; END;
+$$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS mp_designer_touch ON mp_designer_profiles;
+CREATE TRIGGER mp_designer_touch BEFORE UPDATE ON mp_designer_profiles FOR EACH ROW EXECUTE FUNCTION mp_touch_updated_at();
+
+DROP TRIGGER IF EXISTS mp_pattern_touch ON mp_patterns;
+CREATE TRIGGER mp_pattern_touch BEFORE UPDATE ON mp_patterns FOR EACH ROW EXECUTE FUNCTION mp_touch_updated_at();
+
+DROP TRIGGER IF EXISTS mp_collection_touch ON mp_collections;
+CREATE TRIGGER mp_collection_touch BEFORE UPDATE ON mp_collections FOR EACH ROW EXECUTE FUNCTION mp_touch_updated_at();
+
+DROP TRIGGER IF EXISTS mp_user_touch ON mp_users;
+CREATE TRIGGER mp_user_touch BEFORE UPDATE ON mp_users FOR EACH ROW EXECUTE FUNCTION mp_touch_updated_at();
+
+COMMIT;
diff --git a/src/marketplace/ai.js b/src/marketplace/ai.js
new file mode 100644
index 0000000..fc73dce
--- /dev/null
+++ b/src/marketplace/ai.js
@@ -0,0 +1,57 @@
+// AI stub endpoints — deterministic mock JSON, ready to swap for Gemini/Ollama later.
+// All return-shapes match what dashboards / pattern-detail pages will consume.
+
+const PALETTES = [
+ { name: 'Original Artist Palette', hexPalette: ['#2C2A2D','#C9B996','#7A6A5E','#F2EAD8'], description: 'The designer’s primary colourway, balanced for daylight.' },
+ { name: 'Warm Clay', hexPalette: ['#B46A55','#E8C7A9','#6B4B3E','#F4EFE8'], description: 'Earthy reds and creamy plaster, hospitality-ready.' },
+ { name: 'Moss & Linen', hexPalette: ['#69785A','#D7D0B8','#2F3A2D','#F7F4EA'], description: 'Quiet luxury greens with a linen ground.' },
+ { name: 'Ink Blue', hexPalette: ['#1F2E46','#7E9AB8','#E9EDF2','#111827'], description: 'Cinematic indigo for dining and library settings.' },
+ { name: 'Rose Plaster', hexPalette: ['#C98986','#F0D4CF','#80514F','#FFF8F5'], description: 'Soft rose for primary suites and dressing rooms.' },
+ { name: 'Gold Smoke', hexPalette: ['#BFA76A','#E6DDC5','#56514A','#151515'], description: 'High-drama gold on near-black, gallery wall mood.' },
+];
+
+function mount(app) {
+ app.post('/api/marketplace/ai/generate-colorways', (req, res) => {
+ const body = req.body || {};
+ if (!body.patternId && !body.imageUrl && !body.patternSlug) {
+ return res.status(400).json({ error: 'patternId, patternSlug, or imageUrl required' });
+ }
+ res.json({ status: 'mock', message: 'AI colorway placeholder (wire to Gemini/Ollama qwen3:14b)', colorways: PALETTES });
+ });
+
+ app.post('/api/marketplace/ai/tag-pattern', (req, res) => {
+ res.json({
+ status: 'mock',
+ styleTags: ['Botanical', 'Maximalist', 'Romantic'],
+ roomTags: ['Dining Room', 'Powder Room', 'Boutique Hotel'],
+ colorTags: ['Warm Neutral', 'Olive', 'Plaster'],
+ motifTags: ['Foliage', 'Vine', 'Bird'],
+ moodTags: ['Quiet Luxury', 'Editorial'],
+ scale: 'medium-large',
+ mood: 'Soulful, layered, painterly — reads better at 5+ feet of viewing distance.'
+ });
+ });
+
+ app.post('/api/marketplace/ai/write-pattern-description', (req, res) => {
+ const title = (req.body && req.body.title) || 'Untitled Pattern';
+ res.json({
+ status: 'mock',
+ titleSuggestion: title,
+ shortDescription: `${title} — a layered, large-scale wallcovering with painterly motifs.`,
+ longDescription: `${title} reads as a quiet-luxury anchor for a dining room or boutique hotel suite. The drawing is layered without crowding, and the colour family is engineered for warm artificial light and skylit daylight alike. Printed by Designer Wallcoverings, available in non-woven, peel-and-stick, and Type II contract substrates.`,
+ seoTitle: `${title} | Wallco.ai Designer Wallcoverings`,
+ seoDescription: `Shop ${title} — designer-led wallcovering by an independent Wallco creator. Sample first, license, or specify for trade.`
+ });
+ });
+
+ app.post('/api/marketplace/ai/create-room-mockup', (req, res) => {
+ res.json({
+ status: 'mock',
+ mockupUrl: null,
+ promptUsed: 'square room, designer wallpaper applied to back wall, soft natural daylight, mid-century furniture',
+ note: 'Wire to ComfyUI / Replicate / Gemini Image once API key exists; this stub returns shape only.'
+ });
+ });
+}
+
+module.exports = { mount, PALETTES };
diff --git a/src/marketplace/commissions.js b/src/marketplace/commissions.js
new file mode 100644
index 0000000..2ea1c27
--- /dev/null
+++ b/src/marketplace/commissions.js
@@ -0,0 +1,60 @@
+// Commission math — pure functions, no DB.
+// Net Product Revenue = lineItemPrice − discount − refund − sampleCredit (NEVER tax/shipping).
+
+const STATUSES = ['pending', 'approved', 'payable', 'paid', 'reversed', 'disputed'];
+const DEFAULT_RATE = 15;
+const DEFAULT_FOUNDING_RATE = 20;
+const DEFAULT_LICENSE_NONEXCL = 60;
+const DEFAULT_LICENSE_EXCL = 70;
+
+function num(v) { const n = Number(v); return Number.isFinite(n) ? n : 0; }
+
+function calculateNetProductRevenue(input) {
+ const net =
+ num(input.lineItemPrice) -
+ num(input.discountAmount) -
+ num(input.refundAmount) -
+ num(input.sampleCreditAmount);
+ return Number(Math.max(0, net).toFixed(2));
+}
+
+function calculateCommission(input) {
+ const price = num(input.lineItemPrice);
+ if (price < 0) throw new Error('lineItemPrice cannot be negative');
+ const rate = num(input.commissionRate);
+ if (rate < 0 || rate > 100) throw new Error('commissionRate must be between 0 and 100');
+ const basis = calculateNetProductRevenue(input);
+ return Number(((basis * rate) / 100).toFixed(2));
+}
+
+function getDefaultCommissionRate(opts = {}) {
+ if (opts.entryType === 'exclusive_license') return DEFAULT_LICENSE_EXCL;
+ if (opts.entryType === 'license_sale') return DEFAULT_LICENSE_NONEXCL;
+ if (opts.isFounding) return DEFAULT_FOUNDING_RATE;
+ return DEFAULT_RATE;
+}
+
+// Helper: given an order item row + ratesProfile, return {basis, rate, amount} ready to insert into mp_commission_ledger.
+function buildLedgerEntry(orderItem, opts = {}) {
+ const basis = calculateNetProductRevenue({
+ lineItemPrice: orderItem.line_total ?? (num(orderItem.unit_price) * num(orderItem.quantity)),
+ discountAmount: orderItem.discount_amount,
+ refundAmount: orderItem.refund_amount,
+ sampleCreditAmount: orderItem.sample_credit_amount,
+ });
+ const rate = num(orderItem.commission_rate || opts.fallbackRate || DEFAULT_RATE);
+ const amount = Number(((basis * rate) / 100).toFixed(2));
+ return { basis, rate, amount };
+}
+
+module.exports = {
+ STATUSES,
+ DEFAULT_RATE,
+ DEFAULT_FOUNDING_RATE,
+ DEFAULT_LICENSE_NONEXCL,
+ DEFAULT_LICENSE_EXCL,
+ calculateNetProductRevenue,
+ calculateCommission,
+ getDefaultCommissionRate,
+ buildLedgerEntry,
+};
diff --git a/src/marketplace/db.js b/src/marketplace/db.js
new file mode 100644
index 0000000..0c71c1a
--- /dev/null
+++ b/src/marketplace/db.js
@@ -0,0 +1,58 @@
+// Marketplace PG pool — connects to dw_unified.
+// Uses local trust auth on macOS (PGUSER=stevestudio2 default) or DATABASE_URL on Kamatera.
+const { Pool } = require('pg');
+
+// On macOS dev box local trust auth works for the current user against dw_unified.
+// On Kamatera (linux) DATABASE_URL/MARKETPLACE_DB_URL with real creds is required.
+// Prefer explicit MARKETPLACE_DB_URL; on darwin skip the dw_admin DATABASE_URL (which fails
+// locally because that user only exists on prod) and fall through to trust auth.
+const isDarwin = process.platform === 'darwin';
+const url = process.env.MARKETPLACE_DB_URL || (!isDarwin ? process.env.DATABASE_URL : null) || null;
+
+const pool = url
+ ? new Pool({ connectionString: url, max: 10 })
+ : new Pool({
+ host: process.env.PGHOST || (isDarwin ? '/tmp' : '127.0.0.1'),
+ port: Number(process.env.PGPORT || 5432),
+ database: process.env.PGDATABASE || 'dw_unified',
+ user: process.env.PGUSER || process.env.USER || 'stevestudio2',
+ password: process.env.PGPASSWORD || undefined,
+ max: 10,
+ });
+
+pool.on('error', (err) => {
+ console.error('[marketplace.db] pool error:', err.message);
+});
+
+async function q(sql, params = []) {
+ const t0 = Date.now();
+ try {
+ const r = await pool.query(sql, params);
+ return r;
+ } catch (err) {
+ console.error('[marketplace.db] query failed:', err.message, sql.slice(0, 120));
+ throw err;
+ } finally {
+ const ms = Date.now() - t0;
+ if (ms > 500) console.warn('[marketplace.db] slow query', ms + 'ms', sql.slice(0, 80));
+ }
+}
+
+async function one(sql, params = []) {
+ const r = await q(sql, params);
+ return r.rows[0] || null;
+}
+
+async function many(sql, params = []) {
+ const r = await q(sql, params);
+ return r.rows;
+}
+
+async function ping() {
+ try {
+ const r = await q('SELECT 1 AS ok');
+ return !!(r.rows[0] && r.rows[0].ok === 1);
+ } catch (_) { return false; }
+}
+
+module.exports = { pool, q, one, many, ping };
diff --git a/src/marketplace/gamification.js b/src/marketplace/gamification.js
new file mode 100644
index 0000000..af81ef8
--- /dev/null
+++ b/src/marketplace/gamification.js
@@ -0,0 +1,116 @@
+// Gamification — points, levels, badges. Pure functions plus DB-backed grant helpers.
+const { q, one } = require('./db');
+
+const POINTS = {
+ PROFILE_COMPLETED: 25,
+ PATTERN_UPLOADED: 10,
+ PATTERN_APPROVED: 50,
+ COLLECTION_CREATED: 75,
+ FIRST_SALE: 100,
+ SAMPLE_ORDER: 40,
+ FULL_ORDER: 150,
+ TRADE_SAVE: 25,
+ LICENSING_INQUIRY: 100,
+ LICENSE_SALE: 500,
+ FEATURED_COLLECTION: 300,
+ CHALLENGE_WIN: 1000,
+};
+
+const LEVELS = [
+ { name: 'New Creator', points: 0 },
+ { name: 'Pattern Maker', points: 50 },
+ { name: 'Featured Designer', points: 300 },
+ { name: 'Collection Artist', points: 1000 },
+ { name: 'Trade Favorite', points: 2500 },
+ { name: 'Wallco Signature Designer', points: 5000 },
+ { name: 'Hall of Pattern', points: 10000 },
+];
+
+const BADGES = {
+ FOUNDING_DESIGNER: { name: 'Founding Designer', description: 'One of the first creators on Wallco', icon: '🌟' },
+ VERIFIED_DESIGNER: { name: 'Verified Designer', description: 'Identity & rights verified', icon: '✓' },
+ CLAIMED_DESIGNER: { name: 'Claimed Designer', description: 'Claimed and personalized profile', icon: '🪪' },
+ COLOR_GENIUS: { name: 'Color Genius', description: 'Mastery of palette across 10+ patterns', icon: '🎨' },
+ HOSPITALITY_READY: { name: 'Hospitality Ready', description: 'Has commercial-rated patterns', icon: '🏨' },
+ BOUTIQUE_HOTEL_FAVORITE: { name: 'Boutique Hotel Favorite', description: 'Specified on 3+ hospitality projects', icon: '🛎️' },
+ MAXIMALIST_MASTER: { name: 'Maximalist Master', description: 'Bold, layered, large-scale work', icon: '💥' },
+ NEUTRAL_HERO: { name: 'Neutral Hero', description: 'Quiet-luxury favorites', icon: '🤍' },
+ GRASSCLOTH_VISIONARY: { name: 'Grasscloth Visionary', description: 'Excellence in natural-fiber wallcoverings', icon: '🌾' },
+ TRADE_SPECIFIED: { name: 'Trade Specified', description: 'Saved by 10+ trade users', icon: '📐' },
+ LICENSING_READY: { name: 'Licensing Ready', description: 'First licensing inquiry received', icon: '📜' },
+ TOP_SELLER_MONTH: { name: 'Top Seller This Month', description: 'Highest sales of the month', icon: '🏆' },
+ MOST_SAMPLED: { name: 'Most Sampled Pattern', description: 'Most sample orders in 30 days', icon: '🎯' },
+ FIRST_SALE: { name: 'First Sale', description: 'Made first wallpaper sale', icon: '🥳' },
+ COLLECTION_ARTIST: { name: 'Collection Artist', description: 'Published 3+ collections', icon: '📚' },
+ KID_ROOM_FAVORITE: { name: 'Kid Room Favorite', description: 'Saved into 5+ kid-room project boards', icon: '🧸' },
+ HISTORIC_REVIVALIST: { name: 'Historic Revivalist', description: 'Pattern work rooted in historic styles', icon: '🏛️' },
+ AI_RECOLOR_PRO: { name: 'AI Recolor Pro', description: 'Released 5+ AI colorways', icon: '✨' },
+};
+
+function getDesignerLevel(points) {
+ let current = LEVELS[0].name;
+ for (const lvl of LEVELS) if (points >= lvl.points) current = lvl.name;
+ return current;
+}
+
+function getNextLevel(points) {
+ return LEVELS.find((lvl) => points < lvl.points) || null;
+}
+
+function applyAction(currentPoints, action) {
+ const added = POINTS[action] || 0;
+ const newPoints = (currentPoints || 0) + added;
+ return {
+ added,
+ points: newPoints,
+ level: getDesignerLevel(newPoints),
+ nextLevel: getNextLevel(newPoints),
+ };
+}
+
+// DB-backed: record a points event + bump designer total + level.
+async function awardPoints(designerId, action, opts = {}) {
+ if (!POINTS[action]) throw new Error('Unknown action: ' + action);
+ const points = POINTS[action];
+ await q(
+ `INSERT INTO mp_points_ledger (designer_id, action, points, related_pattern_id, related_order_id, related_inquiry_id, notes)
+ VALUES ($1,$2,$3,$4,$5,$6,$7)`,
+ [designerId, action, points, opts.patternId || null, opts.orderId || null, opts.inquiryId || null, opts.notes || null]
+ );
+ const row = await one(
+ `UPDATE mp_designer_profiles
+ SET points = points + $2
+ WHERE id = $1
+ RETURNING points, level`,
+ [designerId, points]
+ );
+ if (!row) return null;
+ const newLevel = getDesignerLevel(row.points);
+ if (newLevel !== row.level) {
+ await q(`UPDATE mp_designer_profiles SET level=$2 WHERE id=$1`, [designerId, newLevel]);
+ }
+ return { points: row.points, level: newLevel, added: points };
+}
+
+async function awardBadge(designerId, badgeKey) {
+ const meta = BADGES[badgeKey];
+ if (!meta) throw new Error('Unknown badge: ' + badgeKey);
+ await q(
+ `INSERT INTO mp_designer_badges (designer_id, badge_key, badge_name, badge_description, icon)
+ VALUES ($1,$2,$3,$4,$5)
+ ON CONFLICT (designer_id, badge_key) DO NOTHING`,
+ [designerId, badgeKey, meta.name, meta.description, meta.icon]
+ );
+ return meta;
+}
+
+module.exports = {
+ POINTS,
+ LEVELS,
+ BADGES,
+ getDesignerLevel,
+ getNextLevel,
+ applyAction,
+ awardPoints,
+ awardBadge,
+};
diff --git a/src/marketplace/index.js b/src/marketplace/index.js
new file mode 100644
index 0000000..e72d853
--- /dev/null
+++ b/src/marketplace/index.js
@@ -0,0 +1,520 @@
+// Wallco Designer Marketplace — single mount point.
+// server.js calls: require('./src/marketplace').mount(app)
+//
+// All routes prefixed /api/marketplace/* (API) and /designers, /store, /patterns, /marketplace/* (HTML).
+// Reads + writes mp_* tables in dw_unified.
+//
+// Auth model:
+// - admin: req.isAdmin (added by upstream isAdmin middleware) — falls back to ADMIN_TOKEN header/query
+// - designer: signed JWT cookie 'mp_designer' (HMAC, 30d)
+// - buyer / trade: optional; not required for public reads
+//
+// Mounts:
+// /api/marketplace/status — public, json health + counts
+// /api/marketplace/designer/apply — POST, public submission
+// /api/marketplace/designer/me — GET, requires designer cookie
+// /api/marketplace/designer/claim — POST, public claim request
+// /api/marketplace/designers — GET, public list
+// /api/marketplace/designers/:slug — GET, public detail
+// /api/marketplace/patterns — GET, public list
+// /api/marketplace/patterns/upload — POST, requires designer cookie
+// /api/marketplace/patterns/:id/approve — POST admin
+// /api/marketplace/commissions/calculate — POST, calculator
+// /api/marketplace/commissions/ledger — GET, designer or admin
+// /api/marketplace/licensing/inquiry — POST, public
+// /api/marketplace/ai/generate-colorways — POST (stub)
+// /api/marketplace/ai/tag-pattern — POST (stub)
+// /api/marketplace/ai/write-pattern-description — POST (stub)
+// /api/marketplace/ai/create-room-mockup — POST (stub)
+// /api/marketplace/admin/* — admin dashboards
+//
+// Pages (HTML) live under public/marketplace/ + are routed below.
+
+const path = require('path');
+const fs = require('fs');
+const crypto = require('crypto');
+const multer = require('multer');
+const { q, one, many, ping } = require('./db');
+const { slugify, shortToken, safeText, emailOk, asArray } = require('./util');
+const commissions = require('./commissions');
+const gam = require('./gamification');
+
+// ── Designer cookie helpers (lightweight HMAC; trust-only, not crypto-grade)
+const MP_SECRET = process.env.MP_DESIGNER_SECRET || process.env.JWT_SECRET || 'wallco-marketplace-dev-secret-rotate-me';
+function signDesigner(designerId) {
+ const payload = Buffer.from(JSON.stringify({ id: designerId, t: Date.now() })).toString('base64url');
+ const sig = crypto.createHmac('sha256', MP_SECRET).update(payload).digest('base64url');
+ return payload + '.' + sig;
+}
+function verifyDesigner(token) {
+ if (!token || typeof token !== 'string' || token.indexOf('.') < 0) return null;
+ const [payload, sig] = token.split('.');
+ const expected = crypto.createHmac('sha256', MP_SECRET).update(payload).digest('base64url');
+ if (sig !== expected) return null;
+ try {
+ const obj = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
+ const age = Date.now() - (obj.t || 0);
+ if (age > 30 * 24 * 60 * 60 * 1000) return null; // 30d
+ return obj.id;
+ } catch (_) { return null; }
+}
+
+function readDesignerCookie(req) {
+ const c = req.headers.cookie || '';
+ const m = c.match(/(?:^|;\s*)mp_designer=([^;]+)/);
+ if (!m) return null;
+ try { return decodeURIComponent(m[1]); } catch (_) { return m[1]; }
+}
+
+async function requireDesigner(req, res, next) {
+ const id = verifyDesigner(readDesignerCookie(req));
+ if (!id) return res.status(401).json({ error: 'login required' });
+ const row = await one(`SELECT id, slug, status, display_name, points, level, is_founding FROM mp_designer_profiles WHERE id=$1`, [id]);
+ if (!row) return res.status(401).json({ error: 'designer not found' });
+ if (row.status === 'rejected' || row.status === 'suspended') return res.status(403).json({ error: 'account ' + row.status });
+ req.designer = row;
+ next();
+}
+
+function requireAdmin(req, res, next) {
+ // Use existing isAdmin gate already set on req by upstream middleware (src/admin-gate),
+ // OR fall back to ADMIN_TOKEN header/query.
+ const admTok = process.env.ADMIN_TOKEN || '';
+ const ok =
+ req.isAdmin === true ||
+ (admTok && (req.headers['x-admin-token'] === admTok || req.query?.admin === admTok));
+ if (!ok) return res.status(403).json({ error: 'admin required' });
+ next();
+}
+
+// ── multer storage for pattern uploads
+const UPLOAD_DIR = path.join(__dirname, '..', '..', 'public', 'marketplace', 'uploads');
+fs.mkdirSync(UPLOAD_DIR, { recursive: true });
+const upload = multer({
+ storage: multer.diskStorage({
+ destination: (_req, _file, cb) => cb(null, UPLOAD_DIR),
+ filename: (_req, file, cb) => {
+ const ext = (file.originalname.match(/\.(jpe?g|png|webp|tiff?|avif)$/i) || ['.jpg'])[0].toLowerCase();
+ cb(null, Date.now() + '-' + shortToken(6) + ext);
+ },
+ }),
+ limits: { fileSize: 50 * 1024 * 1024 }, // 50MB
+ fileFilter: (_req, file, cb) => {
+ if (!/^image\//i.test(file.mimetype)) return cb(new Error('image only'));
+ cb(null, true);
+ },
+});
+
+function mount(app) {
+ // ── public status & health
+ app.get('/api/marketplace/status', async (_req, res) => {
+ try {
+ const live = await ping();
+ const counts = await one(`
+ SELECT
+ (SELECT COUNT(*) FROM mp_designer_profiles) AS designers,
+ (SELECT COUNT(*) FROM mp_designer_profiles WHERE status='approved') AS designers_approved,
+ (SELECT COUNT(*) FROM mp_designer_profiles WHERE is_founding=true) AS founding,
+ (SELECT COUNT(*) FROM mp_designer_claim_pages WHERE outreach_status='unclaimed') AS unclaimed,
+ (SELECT COUNT(*) FROM mp_patterns) AS patterns,
+ (SELECT COUNT(*) FROM mp_patterns WHERE status='approved') AS patterns_approved,
+ (SELECT COUNT(*) FROM mp_patterns WHERE status='pending') AS patterns_pending,
+ (SELECT COUNT(*) FROM mp_collections WHERE status='published') AS collections,
+ (SELECT COUNT(*) FROM mp_commission_ledger) AS commission_entries,
+ (SELECT COALESCE(SUM(commission_amount),0) FROM mp_commission_ledger WHERE status IN ('pending','approved','payable')) AS pending_commission,
+ (SELECT COALESCE(SUM(commission_amount),0) FROM mp_commission_ledger WHERE status='paid') AS paid_commission,
+ (SELECT COUNT(*) FROM mp_licensing_inquiries WHERE status='new') AS open_inquiries,
+ (SELECT COUNT(*) FROM mp_takedown_requests WHERE status='open') AS open_takedowns
+ `);
+ res.json({ ok: true, db: live, ts: new Date().toISOString(), counts });
+ } catch (err) {
+ res.status(500).json({ ok: false, error: err.message });
+ }
+ });
+
+ // ── designer apply
+ app.post('/api/marketplace/designer/apply', async (req, res) => {
+ try {
+ const body = req.body || {};
+ const display = safeText(body.display_name, 80);
+ const email = safeText(body.email, 200);
+ if (!display) return res.status(400).json({ error: 'display_name required' });
+ if (!emailOk(email)) return res.status(400).json({ error: 'valid email required' });
+ if (body.rights_agreement !== true && body.rights_agreement !== 'true') {
+ return res.status(400).json({ error: 'rights_agreement must be accepted' });
+ }
+
+ // Generate unique slug
+ let slug = slugify(display);
+ let suffix = 0;
+ while (true) {
+ const exists = await one(`SELECT id FROM mp_designer_profiles WHERE slug=$1`, [slug]);
+ if (!exists) break;
+ suffix += 1;
+ slug = slugify(display) + '-' + suffix;
+ if (suffix > 50) { slug = slug + '-' + shortToken(3); break; }
+ }
+
+ // user upsert
+ const u = await one(`
+ INSERT INTO mp_users (email, display_name, role)
+ VALUES ($1,$2,'designer')
+ ON CONFLICT (email) DO UPDATE SET display_name=COALESCE(mp_users.display_name, EXCLUDED.display_name), role='designer'
+ RETURNING id`, [email.toLowerCase(), display]);
+
+ const d = await one(`
+ INSERT INTO mp_designer_profiles
+ (user_id, display_name, slug, studio_name, bio, website_url, instagram_url, location, style_tags, status, rights_agreement_at, commission_agreement_at)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'pending',now(),now())
+ RETURNING id, slug, status`,
+ [u.id, display, slug, safeText(body.studio_name, 100), safeText(body.bio, 2000),
+ safeText(body.website_url, 300), safeText(body.instagram_url, 300), safeText(body.location, 150),
+ asArray(body.style_tags)]);
+
+ // award initial profile-complete points
+ try { await gam.awardPoints(d.id, 'PROFILE_COMPLETED'); } catch (_) {}
+
+ // sign cookie so they're "logged in" as that designer
+ const token = signDesigner(d.id);
+ res.setHeader('Set-Cookie', `mp_designer=${encodeURIComponent(token)}; Path=/; Max-Age=2592000; SameSite=Lax; HttpOnly`);
+
+ res.json({ ok: true, designer: { id: d.id, slug: d.slug, status: d.status } });
+ } catch (err) {
+ console.error('[apply] ', err);
+ res.status(500).json({ error: err.message });
+ }
+ });
+
+ // ── current designer
+ app.get('/api/marketplace/designer/me', requireDesigner, async (req, res) => {
+ const d = await one(`SELECT * FROM mp_designer_profiles WHERE id=$1`, [req.designer.id]);
+ const badges = await many(`SELECT badge_key, badge_name, icon, awarded_at FROM mp_designer_badges WHERE designer_id=$1 ORDER BY awarded_at DESC`, [req.designer.id]);
+ const patterns = await many(`SELECT id, title, slug, status, thumbnail_url, view_count, save_count, sample_count, sale_count FROM mp_patterns WHERE designer_id=$1 ORDER BY created_at DESC LIMIT 100`, [req.designer.id]);
+ const ledger = await many(`SELECT action, points, created_at, notes FROM mp_points_ledger WHERE designer_id=$1 ORDER BY created_at DESC LIMIT 50`, [req.designer.id]);
+ const commissions_rows = await many(`SELECT entry_type, basis_amount, commission_rate, commission_amount, status, created_at FROM mp_commission_ledger WHERE designer_id=$1 ORDER BY created_at DESC LIMIT 50`, [req.designer.id]);
+ res.json({ designer: d, badges, patterns, points_ledger: ledger, commission_ledger: commissions_rows, next_level: gam.getNextLevel(d.points || 0) });
+ });
+
+ // ── logout
+ app.post('/api/marketplace/designer/logout', (_req, res) => {
+ res.setHeader('Set-Cookie', `mp_designer=; Path=/; Max-Age=0; SameSite=Lax; HttpOnly`);
+ res.json({ ok: true });
+ });
+
+ // ── claim page submission
+ app.post('/api/marketplace/designer/claim', async (req, res) => {
+ try {
+ const body = req.body || {};
+ const slug = safeText(body.slug, 100);
+ const name = safeText(body.name, 100);
+ const email = safeText(body.email, 200);
+ if (!emailOk(email)) return res.status(400).json({ error: 'valid email required' });
+ if (!name) return res.status(400).json({ error: 'name required' });
+
+ // create or find claim page
+ let claim;
+ if (slug) {
+ claim = await one(`SELECT * FROM mp_designer_claim_pages WHERE slug=$1`, [slug]);
+ }
+ if (!claim) {
+ let s = slugify(name);
+ let n = 0;
+ while (true) {
+ const exists = await one(`SELECT id FROM mp_designer_claim_pages WHERE slug=$1`, [s]);
+ if (!exists) break;
+ n += 1; s = slugify(name) + '-' + n;
+ if (n > 30) break;
+ }
+ claim = await one(`INSERT INTO mp_designer_claim_pages (public_name, slug, source_notes) VALUES ($1,$2,$3) RETURNING *`,
+ [name, s, 'self-claim request']);
+ }
+
+ // log moderation event for admin review
+ await q(`INSERT INTO mp_moderation_events (target_type, target_id, action, reason, metadata)
+ VALUES ('claim',$1,'claim_requested',$2,$3)`,
+ [claim.id, 'Designer requesting claim', JSON.stringify({ name, email, website: safeText(body.website_url, 300), instagram: safeText(body.instagram_url, 300), portfolio: safeText(body.portfolio_url, 300), proof: safeText(body.proof, 2000) })]);
+
+ await q(`UPDATE mp_designer_claim_pages SET outreach_status='contacted' WHERE id=$1 AND outreach_status='unclaimed'`, [claim.id]);
+
+ res.json({ ok: true, claim: { slug: claim.slug, status: 'pending_admin_review' } });
+ } catch (err) {
+ console.error('[claim] ', err);
+ res.status(500).json({ error: err.message });
+ }
+ });
+
+ // ── designer list (public)
+ 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 sort = ({
+ newest: 'created_at DESC',
+ points: 'points DESC',
+ featured: 'is_founding DESC, points DESC',
+ }[req.query.sort] || 'points DESC');
+ 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
+ FROM mp_designer_profiles WHERE status='approved'
+ ORDER BY ${sort} LIMIT $1 OFFSET $2`,
+ [limit, offset]
+ );
+ res.json({ designers: rows, limit, offset });
+ });
+
+ // ── designer detail (public)
+ 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') {
+ const patterns = await many(
+ `SELECT id, title, slug, thumbnail_url, original_image_url, style_tags, color_tags
+ FROM mp_patterns WHERE designer_id=$1 AND status='approved' ORDER BY featured DESC, created_at DESC LIMIT 60`,
+ [d.id]
+ );
+ 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 });
+ }
+ // unclaimed?
+ const claim = await one(`SELECT * FROM mp_designer_claim_pages WHERE slug=$1`, [req.params.slug]);
+ if (claim) return res.json({ designer: null, claim });
+ return res.status(404).json({ error: 'not found' });
+ });
+
+ // ── unclaimed designer pages list (admin only — never expose publicly)
+ app.get('/api/marketplace/admin/claims', requireAdmin, async (_req, res) => {
+ const rows = await many(`SELECT * FROM mp_designer_claim_pages ORDER BY created_at DESC LIMIT 500`);
+ res.json({ claims: rows });
+ });
+
+ // ── pattern list (public, approved only)
+ app.get('/api/marketplace/patterns', async (req, res) => {
+ const limit = Math.min(parseInt(req.query.limit, 10) || 60, 200);
+ const offset = parseInt(req.query.offset, 10) || 0;
+ const designerSlug = req.query.designer || null;
+ const where = ["p.status='approved'"];
+ const params = [];
+ if (designerSlug) {
+ params.push(designerSlug);
+ where.push(`d.slug = $${params.length}`);
+ }
+ params.push(limit); params.push(offset);
+ const sql = `
+ SELECT p.id, p.title, p.slug, p.thumbnail_url, p.original_image_url, p.style_tags, p.color_tags, p.featured,
+ d.display_name AS designer_name, d.slug AS designer_slug, d.id AS designer_id
+ FROM mp_patterns p
+ JOIN mp_designer_profiles d ON d.id = p.designer_id
+ WHERE ${where.join(' AND ')}
+ ORDER BY p.featured DESC, p.created_at DESC
+ LIMIT $${params.length - 1} OFFSET $${params.length}`;
+ const rows = await many(sql, params);
+ res.json({ patterns: rows, limit, offset });
+ });
+
+ // ── pattern detail
+ app.get('/api/marketplace/patterns/:slug', async (req, res) => {
+ const p = await one(
+ `SELECT p.*, d.display_name AS designer_name, d.slug AS designer_slug
+ FROM mp_patterns p JOIN mp_designer_profiles d ON d.id=p.designer_id
+ WHERE p.slug=$1`,
+ [req.params.slug]
+ );
+ if (!p) return res.status(404).json({ error: 'not found' });
+ const colorways = await many(`SELECT id, name, image_url, hex_palette, is_primary FROM mp_pattern_colorways WHERE pattern_id=$1 ORDER BY is_primary DESC, display_order ASC`, [p.id]);
+ await q(`UPDATE mp_patterns SET view_count = view_count + 1 WHERE id=$1`, [p.id]).catch(()=>{});
+ res.json({ pattern: p, colorways });
+ });
+
+ // ── pattern upload
+ app.post('/api/marketplace/patterns/upload', requireDesigner, upload.single('image'), async (req, res) => {
+ try {
+ if (!req.file) return res.status(400).json({ error: 'image required' });
+ const b = req.body || {};
+ if (b.rights_warranty !== 'true' && b.rights_warranty !== true) {
+ return res.status(400).json({ error: 'rights_warranty must be accepted' });
+ }
+ const title = safeText(b.title, 120) || 'Untitled Pattern';
+ let slug = slugify(title);
+ let n = 0;
+ while (true) {
+ const exists = await one(`SELECT id FROM mp_patterns WHERE slug=$1`, [slug]);
+ if (!exists) break;
+ n += 1; slug = slugify(title) + '-' + n;
+ if (n > 30) { slug += '-' + shortToken(3); break; }
+ }
+ const rel = '/marketplace/uploads/' + path.basename(req.file.path);
+ const row = await one(
+ `INSERT INTO mp_patterns
+ (designer_id, title, slug, description, original_image_url, thumbnail_url, repeat_type, scale_notes,
+ motif_tags, style_tags, room_tags, color_tags, mood_tags, commercial_suitable, hospitality_ready,
+ rights_confirmed, rights_confirmed_at, rights_warranty_text, source_type, status)
+ VALUES ($1,$2,$3,$4,$5,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,true,now(),$15,'designer_upload','pending')
+ RETURNING id, slug, status`,
+ [req.designer.id, title, slug, safeText(b.description, 4000), rel,
+ safeText(b.repeat_type, 50), safeText(b.scale_notes, 500),
+ asArray(b.motif_tags), asArray(b.style_tags), asArray(b.room_tags),
+ asArray(b.color_tags), asArray(b.mood_tags),
+ b.commercial_suitable === 'true' || b.commercial_suitable === true,
+ b.hospitality_ready === 'true' || b.hospitality_ready === true,
+ safeText(b.rights_warranty_text, 2000) || 'I warrant that I own or control all rights to this artwork and grant Wallco the rights described in the designer agreement.']
+ );
+ try { await gam.awardPoints(req.designer.id, 'PATTERN_UPLOADED', { patternId: row.id }); } catch (_) {}
+ res.json({ ok: true, pattern: row });
+ } catch (err) {
+ console.error('[upload] ', err);
+ res.status(500).json({ error: err.message });
+ }
+ });
+
+ // ── pattern approve / reject (admin)
+ app.post('/api/marketplace/patterns/:id/approve', requireAdmin, async (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ const row = await one(`UPDATE mp_patterns SET status='approved' WHERE id=$1 RETURNING id, designer_id`, [id]);
+ if (!row) return res.status(404).json({ error: 'not found' });
+ try { await gam.awardPoints(row.designer_id, 'PATTERN_APPROVED', { patternId: id }); } catch (_) {}
+ await q(`INSERT INTO mp_moderation_events (target_type, target_id, action) VALUES ('pattern',$1,'approved')`, [id]);
+ res.json({ ok: true, pattern: row });
+ });
+
+ app.post('/api/marketplace/patterns/:id/reject', requireAdmin, async (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ const reason = safeText(req.body?.reason, 500);
+ const row = await one(`UPDATE mp_patterns SET status='rejected', rejection_reason=$2 WHERE id=$1 RETURNING id`, [id, reason]);
+ if (!row) return res.status(404).json({ error: 'not found' });
+ await q(`INSERT INTO mp_moderation_events (target_type, target_id, action, reason) VALUES ('pattern',$1,'rejected',$2)`, [id, reason]);
+ res.json({ ok: true });
+ });
+
+ // ── designer approve (admin)
+ app.post('/api/marketplace/designers/:id/approve', requireAdmin, async (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ const row = await one(`UPDATE mp_designer_profiles SET status='approved' WHERE id=$1 RETURNING id`, [id]);
+ if (!row) return res.status(404).json({ error: 'not found' });
+ await q(`INSERT INTO mp_moderation_events (target_type, target_id, action) VALUES ('designer',$1,'approved')`, [id]);
+ res.json({ ok: true });
+ });
+
+ app.post('/api/marketplace/designers/:id/verify', requireAdmin, async (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ const row = await one(`UPDATE mp_designer_profiles SET is_verified=true WHERE id=$1 RETURNING id`, [id]);
+ if (!row) return res.status(404).json({ error: 'not found' });
+ try { await gam.awardBadge(id, 'VERIFIED_DESIGNER'); } catch (_) {}
+ res.json({ ok: true });
+ });
+
+ app.post('/api/marketplace/designers/:id/founding', requireAdmin, async (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ await q(`UPDATE mp_designer_profiles SET is_founding=true, commission_rate=GREATEST(commission_rate, $2) WHERE id=$1`,
+ [id, commissions.DEFAULT_FOUNDING_RATE]);
+ try { await gam.awardBadge(id, 'FOUNDING_DESIGNER'); } catch (_) {}
+ res.json({ ok: true });
+ });
+
+ // ── commission calculator (public pure)
+ app.post('/api/marketplace/commissions/calculate', (req, res) => {
+ try {
+ const out = {
+ net_basis: commissions.calculateNetProductRevenue(req.body || {}),
+ commission: commissions.calculateCommission(req.body || {}),
+ };
+ res.json(out);
+ } catch (err) { res.status(400).json({ error: err.message }); }
+ });
+
+ // ── commission ledger (designer self OR admin)
+ app.get('/api/marketplace/commissions/ledger', async (req, res) => {
+ const designerId = verifyDesigner(readDesignerCookie(req));
+ const admTok = process.env.ADMIN_TOKEN || '';
+ const isAdm = req.isAdmin === true || (admTok && (req.headers['x-admin-token'] === admTok || req.query?.admin === admTok));
+ if (!designerId && !isAdm) return res.status(401).json({ error: 'login required' });
+ const where = isAdm ? '1=1' : 'designer_id=$1';
+ const params = isAdm ? [] : [designerId];
+ const rows = await many(`SELECT * FROM mp_commission_ledger WHERE ${where} ORDER BY created_at DESC LIMIT 500`, params);
+ res.json({ ledger: rows });
+ });
+
+ // ── licensing inquiry
+ app.post('/api/marketplace/licensing/inquiry', async (req, res) => {
+ try {
+ const b = req.body || {};
+ const patternSlug = safeText(b.pattern_slug, 100);
+ const email = safeText(b.buyer_email, 200);
+ if (!emailOk(email)) return res.status(400).json({ error: 'valid email required' });
+ const p = patternSlug ? await one(`SELECT id, designer_id FROM mp_patterns WHERE slug=$1`, [patternSlug]) : null;
+ const inq = await one(
+ `INSERT INTO mp_licensing_inquiries
+ (pattern_id, designer_id, buyer_name, buyer_email, buyer_company, license_type, usage_description, territory, duration_months, budget_range)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
+ RETURNING id`,
+ [p?.id || null, p?.designer_id || null,
+ safeText(b.buyer_name, 100) || 'Anonymous', email.toLowerCase(),
+ safeText(b.buyer_company, 200), b.license_type || 'non_exclusive',
+ safeText(b.usage_description, 4000), safeText(b.territory, 200),
+ parseInt(b.duration_months, 10) || null, safeText(b.budget_range, 100)]
+ );
+ if (p?.designer_id) {
+ try { await gam.awardPoints(p.designer_id, 'LICENSING_INQUIRY', { inquiryId: inq.id }); } catch (_) {}
+ try { await gam.awardBadge(p.designer_id, 'LICENSING_READY'); } catch (_) {}
+ }
+ res.json({ ok: true, inquiry_id: inq.id });
+ } catch (err) {
+ console.error('[licensing] ', err);
+ res.status(500).json({ error: err.message });
+ }
+ });
+
+ // ── project save (trade workflow)
+ app.post('/api/marketplace/projects', requireDesigner, async (req, res) => {
+ // also works for any logged-in user; for now we use designer cookie as a stand-in
+ const b = req.body || {};
+ const row = await one(
+ `INSERT INTO mp_projects (user_id, title, client_name, room_type, notes)
+ VALUES ((SELECT user_id FROM mp_designer_profiles WHERE id=$1), $2,$3,$4,$5)
+ RETURNING *`,
+ [req.designer.id, safeText(b.title, 200) || 'Untitled Project', safeText(b.client_name, 200), safeText(b.room_type, 100), safeText(b.notes, 2000)]
+ );
+ res.json({ project: row });
+ });
+
+ app.post('/api/marketplace/projects/:id/save', requireDesigner, async (req, res) => {
+ const projId = parseInt(req.params.id, 10);
+ const b = req.body || {};
+ const pat = await one(`SELECT id, designer_id FROM mp_patterns WHERE slug=$1 OR id=$2`, [safeText(b.pattern_slug, 100), parseInt(b.pattern_id, 10) || 0]);
+ if (!pat) return res.status(404).json({ error: 'pattern not found' });
+ const row = await one(
+ `INSERT INTO mp_project_saves (project_id, pattern_id, colorway_id, notes)
+ VALUES ($1,$2,$3,$4)
+ ON CONFLICT (project_id, pattern_id, colorway_id) DO UPDATE SET notes=EXCLUDED.notes
+ RETURNING *`,
+ [projId, pat.id, parseInt(b.colorway_id, 10) || null, safeText(b.notes, 2000)]
+ );
+ await q(`UPDATE mp_patterns SET save_count = save_count + 1 WHERE id=$1`, [pat.id]).catch(()=>{});
+ try { await gam.awardPoints(pat.designer_id, 'TRADE_SAVE', { patternId: pat.id }); } catch (_) {}
+ res.json({ save: row });
+ });
+
+ // ── AI stubs
+ require('./ai').mount(app);
+
+ // ── HTML pages — serve from public/marketplace/
+ const pagesDir = path.join(__dirname, '..', '..', 'public', 'marketplace');
+ function sendPage(name) {
+ return (_req, res) => res.sendFile(path.join(pagesDir, name));
+ }
+ app.get('/marketplace', sendPage('index.html'));
+ app.get('/marketplace/', sendPage('index.html'));
+ app.get('/marketplace/status', sendPage('status.html'));
+ app.get('/marketplace/apply', sendPage('apply.html'));
+ app.get('/marketplace/dashboard', sendPage('dashboard.html'));
+ app.get('/marketplace/admin', sendPage('admin.html'));
+ app.get('/designers', sendPage('designers.html'));
+ app.get('/designers/:slug', sendPage('designer-profile.html'));
+ app.get('/store/:slug', sendPage('storefront.html'));
+ app.get('/patterns', sendPage('patterns.html'));
+ app.get('/patterns/:slug', sendPage('pattern.html'));
+ app.get('/licensing/:slug', sendPage('licensing.html'));
+ app.get('/marketplace/claim', sendPage('claim.html'));
+
+ console.log(' Marketplace layer mounted (/api/marketplace, /designers, /store, /patterns, /marketplace)');
+}
+
+module.exports = { mount };
diff --git a/src/marketplace/util.js b/src/marketplace/util.js
new file mode 100644
index 0000000..7bf03cc
--- /dev/null
+++ b/src/marketplace/util.js
@@ -0,0 +1,34 @@
+// Small helpers shared across marketplace routes.
+const crypto = require('crypto');
+
+function slugify(s) {
+ return String(s || '')
+ .toLowerCase()
+ .normalize('NFKD').replace(/[̀-ͯ]/g, '')
+ .replace(/['"]/g, '')
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 80) || 'untitled';
+}
+
+function shortToken(bytes = 12) {
+ return crypto.randomBytes(bytes).toString('base64url');
+}
+
+function safeText(v, max = 5000) {
+ if (v == null) return null;
+ const s = String(v).trim();
+ return s.length > max ? s.slice(0, max) : s;
+}
+
+function emailOk(s) {
+ return typeof s === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s.trim());
+}
+
+function asArray(v) {
+ if (!v) return [];
+ if (Array.isArray(v)) return v.map((x) => String(x).trim()).filter(Boolean);
+ return String(v).split(',').map((x) => x.trim()).filter(Boolean);
+}
+
+module.exports = { slugify, shortToken, safeText, emailOk, asArray };
diff --git a/tests/marketplace/commissions.test.js b/tests/marketplace/commissions.test.js
new file mode 100644
index 0000000..be74796
--- /dev/null
+++ b/tests/marketplace/commissions.test.js
@@ -0,0 +1,70 @@
+// Unit tests for src/marketplace/commissions.js
+// Run with: node tests/marketplace/commissions.test.js
+
+const assert = require('assert');
+const c = require('../../src/marketplace/commissions');
+
+const tests = [];
+function t(name, fn) { tests.push({ name, fn }); }
+
+t('calculateNetProductRevenue subtracts discount + refund + sample credit, never tax/shipping', () => {
+ const net = c.calculateNetProductRevenue({
+ lineItemPrice: 1000,
+ discountAmount: 100,
+ refundAmount: 50,
+ sampleCreditAmount: 5,
+ taxAmount: 88, // ignored
+ shippingAmount: 25, // ignored
+ });
+ assert.strictEqual(net, 845);
+});
+
+t('calculateNetProductRevenue clamps to 0 (no negative)', () => {
+ const net = c.calculateNetProductRevenue({ lineItemPrice: 50, refundAmount: 100 });
+ assert.strictEqual(net, 0);
+});
+
+t('calculateCommission default founding 20% on net basis', () => {
+ const com = c.calculateCommission({ lineItemPrice: 1000, discountAmount: 100, commissionRate: 20 });
+ assert.strictEqual(com, 180); // (1000-100) * .20
+});
+
+t('calculateCommission rejects negative price', () => {
+ assert.throws(() => c.calculateCommission({ lineItemPrice: -1, commissionRate: 15 }));
+});
+
+t('calculateCommission rejects out-of-range rate', () => {
+ assert.throws(() => c.calculateCommission({ lineItemPrice: 100, commissionRate: 150 }));
+ assert.throws(() => c.calculateCommission({ lineItemPrice: 100, commissionRate: -1 }));
+});
+
+t('getDefaultCommissionRate returns founding 20 when isFounding=true', () => {
+ assert.strictEqual(c.getDefaultCommissionRate({ isFounding: true }), 20);
+ assert.strictEqual(c.getDefaultCommissionRate({}), 15);
+});
+
+t('getDefaultCommissionRate returns 60 for license_sale, 70 for exclusive_license', () => {
+ assert.strictEqual(c.getDefaultCommissionRate({ entryType: 'license_sale' }), 60);
+ assert.strictEqual(c.getDefaultCommissionRate({ entryType: 'exclusive_license' }), 70);
+});
+
+t('buildLedgerEntry from order item: basis + rate + amount', () => {
+ const out = c.buildLedgerEntry({
+ line_total: 496,
+ discount_amount: 0,
+ refund_amount: 0,
+ sample_credit_amount: 0,
+ commission_rate: 20,
+ });
+ assert.strictEqual(out.basis, 496);
+ assert.strictEqual(out.rate, 20);
+ assert.strictEqual(out.amount, 99.20);
+});
+
+let pass = 0, fail = 0;
+for (const test of tests) {
+ try { test.fn(); console.log('✓', test.name); pass++; }
+ catch (err) { console.error('✗', test.name, '—', err.message); fail++; }
+}
+console.log(`\n${pass}/${tests.length} tests passed`);
+if (fail) process.exit(1);
diff --git a/tests/marketplace/gamification.test.js b/tests/marketplace/gamification.test.js
new file mode 100644
index 0000000..b89edb9
--- /dev/null
+++ b/tests/marketplace/gamification.test.js
@@ -0,0 +1,48 @@
+// Unit tests for src/marketplace/gamification.js
+// Pure-function tests only (no DB).
+const assert = require('assert');
+const g = require('../../src/marketplace/gamification');
+
+const tests = [];
+function t(name, fn) { tests.push({ name, fn }); }
+
+t('getDesignerLevel returns New Creator at 0', () => assert.strictEqual(g.getDesignerLevel(0), 'New Creator'));
+t('getDesignerLevel returns Pattern Maker at 50', () => assert.strictEqual(g.getDesignerLevel(50), 'Pattern Maker'));
+t('getDesignerLevel returns Featured Designer at 300', () => assert.strictEqual(g.getDesignerLevel(300), 'Featured Designer'));
+t('getDesignerLevel returns Hall of Pattern at 10000+', () => assert.strictEqual(g.getDesignerLevel(15000), 'Hall of Pattern'));
+
+t('getNextLevel from 0 → Pattern Maker', () => assert.strictEqual(g.getNextLevel(0).name, 'Pattern Maker'));
+t('getNextLevel from 5500 → Hall of Pattern', () => assert.strictEqual(g.getNextLevel(5500).name, 'Hall of Pattern'));
+t('getNextLevel at top returns null', () => assert.strictEqual(g.getNextLevel(20000), null));
+
+t('applyAction adds points and recomputes level', () => {
+ const out = g.applyAction(40, 'PATTERN_APPROVED'); // 40 + 50 = 90
+ assert.strictEqual(out.added, 50);
+ assert.strictEqual(out.points, 90);
+ assert.strictEqual(out.level, 'Pattern Maker');
+});
+
+t('applyAction works for LICENSE_SALE (500 points)', () => {
+ const out = g.applyAction(2400, 'LICENSE_SALE'); // 2400 + 500 = 2900
+ assert.strictEqual(out.points, 2900);
+ assert.strictEqual(out.level, 'Trade Favorite');
+});
+
+t('POINTS table covers all designer actions', () => {
+ for (const action of ['PROFILE_COMPLETED','PATTERN_UPLOADED','PATTERN_APPROVED','FIRST_SALE','SAMPLE_ORDER','FULL_ORDER','LICENSING_INQUIRY','LICENSE_SALE','FEATURED_COLLECTION','CHALLENGE_WIN']) {
+ assert.ok(typeof g.POINTS[action] === 'number' && g.POINTS[action] > 0, action + ' has positive points');
+ }
+});
+
+t('BADGES include FOUNDING_DESIGNER + VERIFIED_DESIGNER', () => {
+ assert.ok(g.BADGES.FOUNDING_DESIGNER && g.BADGES.FOUNDING_DESIGNER.name === 'Founding Designer');
+ assert.ok(g.BADGES.VERIFIED_DESIGNER && g.BADGES.VERIFIED_DESIGNER.name === 'Verified Designer');
+});
+
+let pass = 0, fail = 0;
+for (const test of tests) {
+ try { test.fn(); console.log('✓', test.name); pass++; }
+ catch (err) { console.error('✗', test.name, '—', err.message); fail++; }
+}
+console.log(`\n${pass}/${tests.length} tests passed`);
+if (fail) process.exit(1);
diff --git a/tests/marketplace/smoke.test.js b/tests/marketplace/smoke.test.js
new file mode 100644
index 0000000..38507c3
--- /dev/null
+++ b/tests/marketplace/smoke.test.js
@@ -0,0 +1,102 @@
+// HTTP smoke tests against running wallco-ai on :9792.
+// Run with: node tests/marketplace/smoke.test.js
+const assert = require('assert');
+const http = require('http');
+
+const HOST = process.env.WALLCO_HOST || '127.0.0.1';
+const PORT = process.env.WALLCO_PORT || 9792;
+
+function req(method, path, body) {
+ return new Promise((resolve, reject) => {
+ const opts = { hostname: HOST, port: PORT, path, method, headers: { 'content-type': 'application/json' } };
+ const r = http.request(opts, (res) => {
+ let buf = ''; res.on('data', c => buf += c); res.on('end', () => resolve({ status: res.statusCode, body: buf, json: tryJson(buf) }));
+ });
+ r.on('error', reject);
+ if (body) r.write(JSON.stringify(body));
+ r.end();
+ });
+}
+function tryJson(s){ try { return JSON.parse(s); } catch(_) { return null; } }
+
+const tests = [];
+function t(name, fn) { tests.push({ name, fn }); }
+
+t('GET /api/marketplace/status returns ok=true', async () => {
+ const r = await req('GET', '/api/marketplace/status');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.json.ok, true);
+ assert.ok(r.json.counts && Number(r.json.counts.designers) >= 0);
+});
+
+t('GET /api/marketplace/designers returns array', async () => {
+ const r = await req('GET', '/api/marketplace/designers');
+ assert.strictEqual(r.status, 200);
+ assert.ok(Array.isArray(r.json.designers));
+});
+
+t('GET /api/marketplace/designers/:slug returns designer payload', async () => {
+ const r = await req('GET', '/api/marketplace/designers/astrid-mauve');
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.json.designer);
+ assert.ok(Array.isArray(r.json.patterns));
+});
+
+t('GET /api/marketplace/patterns returns array', async () => {
+ const r = await req('GET', '/api/marketplace/patterns');
+ assert.strictEqual(r.status, 200);
+ assert.ok(Array.isArray(r.json.patterns));
+});
+
+t('POST commissions/calculate with $1000 line, 20% rate', async () => {
+ const r = await req('POST', '/api/marketplace/commissions/calculate', { lineItemPrice: 1000, commissionRate: 20 });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.json.commission, 200);
+});
+
+t('POST ai/generate-colorways returns 6 palettes', async () => {
+ const r = await req('POST', '/api/marketplace/ai/generate-colorways', { patternSlug: 'wildflower-reverie-astrid-mauve' });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.json.colorways.length, 6);
+});
+
+t('POST ai/tag-pattern returns tag families', async () => {
+ const r = await req('POST', '/api/marketplace/ai/tag-pattern', {});
+ assert.strictEqual(r.status, 200);
+ assert.ok(Array.isArray(r.json.styleTags));
+});
+
+t('POST licensing/inquiry persists', async () => {
+ const r = await req('POST', '/api/marketplace/licensing/inquiry', {
+ pattern_slug: 'hotel-bluff-astrid-mauve',
+ buyer_name: 'Smoke Test',
+ buyer_email: 'smoke@test.invalid',
+ license_type: 'non_exclusive',
+ usage_description: 'smoke test'
+ });
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.json.inquiry_id);
+});
+
+t('POST designer/apply requires rights_agreement', async () => {
+ const r = await req('POST', '/api/marketplace/designer/apply', { display_name: 'X', email: 'x@y.invalid' });
+ assert.strictEqual(r.status, 400);
+ assert.ok(/rights_agreement/.test(r.json.error || ''));
+});
+
+t('Page routes serve HTML (200)', async () => {
+ for (const path of ['/marketplace','/designers','/patterns','/marketplace/status','/marketplace/apply','/marketplace/dashboard','/marketplace/admin','/marketplace/claim','/designers/astrid-mauve','/store/astrid-mauve']) {
+ const r = await req('GET', path);
+ assert.strictEqual(r.status, 200, path + ' → ' + r.status);
+ }
+});
+
+(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} smoke tests passed`);
+ if (fail) process.exit(1);
+})();
← 029a6cf design coordinate: collapse paint chips into <details> table
·
back to Wallco Ai
·
bh-abstract-leaves: regen all 44 as contemporary abstract vi 6f6143e →