← back to Patterndesignlab
express server (search/facets/sort/checkout guard/admin) + marketplace, detail w/ tile-on-wall, designer, admin pages
23e1fcf6134fd0a975c214ff400a9bd2ce9a7cb1 · 2026-07-04 11:14:25 -0700 · Steve
Files touched
A public/admin.htmlA public/design.htmlA public/designer.htmlA public/index.htmlA public/styles.cssA server.js
Diff
commit 23e1fcf6134fd0a975c214ff400a9bd2ce9a7cb1
Author: Steve <steve@designerwallcoverings.com>
Date: Sat Jul 4 11:14:25 2026 -0700
express server (search/facets/sort/checkout guard/admin) + marketplace, detail w/ tile-on-wall, designer, admin pages
---
public/admin.html | 63 ++++++++++++++++++++
public/design.html | 92 +++++++++++++++++++++++++++++
public/designer.html | 48 +++++++++++++++
public/index.html | 128 ++++++++++++++++++++++++++++++++++++++++
public/styles.css | 126 ++++++++++++++++++++++++++++++++++++++++
server.js | 161 +++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 618 insertions(+)
diff --git a/public/admin.html b/public/admin.html
new file mode 100644
index 0000000..29ce16a
--- /dev/null
+++ b/public/admin.html
@@ -0,0 +1,63 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Admin · Pattern Design Lab</title>
+<link rel="stylesheet" href="/styles.css">
+</head>
+<body>
+<header class="top"><div class="wrap">
+ <a class="brand" href="/">Pattern Design<span class="dot">.</span>Lab <small>admin</small></a>
+ <nav class="navlinks"><a href="/">Storefront</a></nav>
+</div></header>
+
+<div class="wrap">
+ <div class="adminbar">
+ <h1 style="margin:0;font-size:22px">Design catalog</h1>
+ <span class="count" id="count" style="color:#6b6b6b"></span>
+ <div style="margin-left:auto"><label for="sort" style="font-size:12px;color:#6b6b6b">Sort</label>
+ <select id="sort" onchange="render()" style="height:36px;border:1px solid var(--line);border-radius:8px;padding:0 10px">
+ <option value="newest">Newest</option><option value="oldest">Oldest</option>
+ <option value="title">Title A→Z</option><option value="designer">Designer</option>
+ </select></div>
+ </div>
+ <div class="admgrid" id="grid"></div>
+</div>
+
+<script>
+let rows=[];
+function esc(s){ return (s==null?'':String(s)).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
+// Steve standing rule: every admin card shows created DATE + TIME (local tz), ISO in title attr.
+function fmtDate(iso){ try{ return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }catch{ return iso; } }
+
+async function load(){ const j=await (await fetch('/api/admin/designs')).json(); rows=j.designs; document.getElementById('count').textContent=j.count+' designs'; render(); }
+function render(){
+ const s=document.getElementById('sort').value;
+ const r=[...rows];
+ if(s==='newest') r.sort((a,b)=>new Date(b.created_at)-new Date(a.created_at));
+ if(s==='oldest') r.sort((a,b)=>new Date(a.created_at)-new Date(b.created_at));
+ if(s==='title') r.sort((a,b)=>a.title.localeCompare(b.title));
+ if(s==='designer') r.sort((a,b)=>(a.designer_name||'').localeCompare(b.designer_name||''));
+ document.getElementById('grid').innerHTML = r.map(d=>`
+ <div class="acard">
+ <div class="t" style="background-image:url('${d.img}')"></div>
+ <div class="b">
+ <h3>${esc(d.title)}</h3>
+ <div class="when" title="${esc(d.created_at)}">🕓 ${fmtDate(d.created_at)}</div>
+ <div class="row">
+ <span class="tag">${esc(d.designer_name)}</span>
+ <span class="tag">${esc(d.style||'')}</span>
+ <span class="tag"><span class="swatch" style="display:inline-block;vertical-align:-1px;background:${d.dominant_hex}"></span> ${esc(d.colorway||'')}</span>
+ <span class="tag">${d.seamless?'seamless':'mural'}</span>
+ <span class="tag">${esc(d.pricing_status||'')}</span>
+ <span class="tag">${d.price_min!=null?'$'+d.price_min+(d.price_max&&d.price_max!=d.price_min?'–$'+d.price_max:''):'no price'}</span>
+ </div>
+ <div style="margin-top:8px"><a href="/design/${esc(d.id)}" style="color:var(--accent-ink);font-size:13px">Open →</a></div>
+ </div>
+ </div>`).join('');
+}
+load();
+</script>
+</body>
+</html>
diff --git a/public/design.html b/public/design.html
new file mode 100644
index 0000000..5765e83
--- /dev/null
+++ b/public/design.html
@@ -0,0 +1,92 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Pattern · Pattern Design Lab</title>
+<link rel="stylesheet" href="/styles.css">
+</head>
+<body>
+<header class="top"><div class="wrap">
+ <a class="brand" href="/">Pattern Design<span class="dot">.</span>Lab</a>
+ <form class="searchbox" onsubmit="location.href='/?';return false"><input type="search" placeholder="Search…" onkeydown="if(event.key==='Enter'){location.href='/'}"></form>
+ <nav class="navlinks"><a href="/">Explore</a></nav>
+</div></header>
+
+<div class="wrap" id="root"><p style="padding:40px 0;color:#6b6b6b">Loading…</p></div>
+<footer><div class="wrap">Pattern Design Lab · owned, settlement-passed originals only</div></footer>
+
+<script>
+const id = decodeURIComponent(location.pathname.split('/').pop());
+let sel = null, design = null;
+function esc(s){ return (s==null?'':String(s)).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
+
+async function load(){
+ const r = await fetch('/api/designs/'+encodeURIComponent(id));
+ if(!r.ok){ document.getElementById('root').innerHTML='<p style="padding:40px 0">Pattern not found. <a href="/">Back to explore</a></p>'; return; }
+ design = await r.json();
+ const tiers = design.license_tiers||[];
+ sel = tiers[0] ? tiers[0].tier : null;
+ const tags = (design.tags||[]).slice(0,12);
+ document.title = design.title + ' · Pattern Design Lab';
+ document.getElementById('root').innerHTML = `
+ <div class="detail">
+ <div class="preview">
+ <div class="big" style="background-image:url('${design.img}')"></div>
+ <div class="wallwrap">
+ <div class="wall">
+ <div class="paper" id="paper" style="background-image:url('${design.img}');background-size:22% 22%"></div>
+ <div class="floor"></div><div class="sofa"></div><div class="lamp"></div><div class="shade"></div>
+ </div>
+ <div class="scalebar"><span style="font-size:12px;color:#6b6b6b">Repeat scale</span>
+ <input type="range" min="8" max="45" value="22" oninput="document.getElementById('paper').style.backgroundSize=this.value+'% '+this.value+'%'">
+ </div>
+ <p class="note">Live seamless tile-on-wall preview — the pattern repeats edge-to-edge. Drag to resize the repeat.</p>
+ </div>
+ </div>
+
+ <div class="info">
+ <h1 class="dtitle">${esc(design.title)}</h1>
+ <p class="dsub">${esc(design.style||'')} · by <a href="/designer/${design.designer_slug}" style="color:var(--accent-ink)">${esc(design.designer_name)}</a>${design.designer_country?' · '+esc(design.designer_country):''}</p>
+ ${design.style_line?`<p style="color:#4a4a4a">${esc(design.style_line)}</p>`:''}
+
+ <div class="specs">
+ <div><div class="k">Colorway</div><div class="v"><span class="swatch" style="display:inline-block;background:${design.dominant_hex}"></span> ${esc(design.colorway||'—')}</div></div>
+ <div><div class="k">Motif</div><div class="v">${esc(design.motif||'—')}</div></div>
+ <div><div class="k">Technique</div><div class="v">${esc(design.technique||'—')}</div></div>
+ <div><div class="k">Seamless</div><div class="v">${design.seamless?'Yes — tiles edge-to-edge':'No — mural panel'}</div></div>
+ </div>
+
+ <h3 style="margin:22px 0 6px;font-size:15px">Choose a license</h3>
+ <div class="tiers" id="tiers">
+ ${tiers.map(t=>`<div class="tier ${t.tier===sel?'sel':''}" data-tier="${t.tier}" onclick="pick('${t.tier}')">
+ <span class="tp">${esc(t.label||t.tier)}</span><span>$${t.priceUsd}</span></div>`).join('') || '<p class="note">Licensing tiers coming soon.</p>'}
+ </div>
+
+ <input id="email" type="email" placeholder="your@email.com (optional)" style="width:100%;height:42px;border:1px solid var(--line);border-radius:10px;padding:0 12px;margin-bottom:10px;font-size:14px">
+ <button class="buy" onclick="buy()" ${tiers.length?'':'disabled'}>License this pattern</button>
+ <p class="note" id="buynote"></p>
+
+ <div class="tagrow">${tags.map(t=>`<span class="tag">${esc(t)}</span>`).join('')}</div>
+ </div>
+ </div>`;
+ fetch('/api/config').then(r=>r.json()).then(c=>{
+ document.getElementById('buynote').textContent = c.checkoutReady
+ ? 'Secure checkout — Stripe TEST mode (no real charge).'
+ : 'Checkout opens when payment is enabled; for now this records a licensing inquiry.';
+ });
+}
+function pick(t){ sel=t; document.querySelectorAll('.tier').forEach(e=>e.classList.toggle('sel', e.dataset.tier===t)); }
+async function buy(){
+ const note=document.getElementById('buynote');
+ const r = await fetch('/api/license/checkout',{method:'POST',headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({designId:design.id,tier:sel,email:document.getElementById('email').value})});
+ const j = await r.json();
+ if(j.url){ location.href=j.url; return; }
+ if(j.ok){ note.textContent='✓ '+(j.note||'Recorded — we\'ll be in touch.'); note.style.color='var(--accent-ink)'; }
+ else { note.textContent='Could not start checkout: '+(j.error||'error'); }
+}
+load();
+</script>
+</body>
+</html>
diff --git a/public/designer.html b/public/designer.html
new file mode 100644
index 0000000..f36e849
--- /dev/null
+++ b/public/designer.html
@@ -0,0 +1,48 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Designer · Pattern Design Lab</title>
+<link rel="stylesheet" href="/styles.css">
+</head>
+<body>
+<header class="top"><div class="wrap">
+ <a class="brand" href="/">Pattern Design<span class="dot">.</span>Lab</a>
+ <nav class="navlinks"><a href="/">Explore</a></nav>
+</div></header>
+<div class="wrap" id="root"><p style="padding:40px 0;color:#6b6b6b">Loading…</p></div>
+<footer><div class="wrap">Pattern Design Lab · owned, settlement-passed originals only</div></footer>
+<script>
+const slug = decodeURIComponent(location.pathname.split('/').pop());
+function esc(s){ return (s==null?'':String(s)).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
+function fmtPrice(lo){ return lo==null?'Inquire':'$'+lo; }
+(async()=>{
+ const r = await fetch('/api/designers/'+encodeURIComponent(slug));
+ if(!r.ok){ document.getElementById('root').innerHTML='<p style="padding:40px 0">Designer not found. <a href="/">Explore</a></p>'; return; }
+ const {designer:d, designs, count} = await r.json();
+ document.title = d.name + ' · Pattern Design Lab';
+ document.getElementById('root').innerHTML = `
+ <div class="dhero">
+ <div class="av" style="background-image:url('${d.avatar||''}')"></div>
+ <div><h1>${esc(d.name)}</h1>
+ <p>${esc(d.bio||'')}</p>
+ <p style="font-size:13px;color:#6b6b6b;margin-top:6px">${esc(d.country||'')} · ${count} design${count===1?'':'s'}</p></div>
+ </div>
+ <div class="bar"><span class="count">${count} designs</span>
+ <label for="density">Density</label>
+ <input id="density" type="range" min="2" max="6" step="1" value="4" oninput="document.documentElement.style.setProperty('--cols',this.value);localStorage.setItem('pdl.density',this.value)"></div>
+ <div class="grid">${designs.map(x=>`
+ <a class="card" href="/design/${x.slug}">
+ <div class="thumb"><div class="tile" style="background-image:url('${x.img}')"></div>
+ ${x.seamless?'<span class="badge">Seamless</span>':'<span class="badge">Mural</span>'}</div>
+ <div class="cardbody"><h3>${esc(x.title)}</h3>
+ <div class="meta"><span class="swatch" style="background:${x.dominant_hex||'#ccc'}"></span><span>${esc(x.style||'')}</span><span>·</span><span>${esc(x.colorway||'')}</span></div>
+ <div class="price">${fmtPrice(x.price_min)}</div></div>
+ </a>`).join('')}</div>
+ <div style="padding:24px 0"><a href="/" style="color:var(--accent-ink)">← Back to all patterns</a></div>`;
+ const dd=localStorage.getItem('pdl.density'); if(dd){document.documentElement.style.setProperty('--cols',dd);const el=document.getElementById('density');if(el)el.value=dd;}
+})();
+</script>
+</body>
+</html>
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..9e6da3c
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,128 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Pattern Design Lab — seamless patterns, licensed</title>
+<link rel="stylesheet" href="/styles.css">
+</head>
+<body>
+<header class="top"><div class="wrap">
+ <a class="brand" href="/">Pattern Design<span class="dot">.</span>Lab <small>seamless originals, licensed</small></a>
+ <form class="searchbox" onsubmit="return doSearch(event)">
+ <input id="q" type="search" placeholder="Search patterns, styles, colorways…" autocomplete="off">
+ <button class="go" type="submit" aria-label="search">→</button>
+ </form>
+ <nav class="navlinks"><a href="/">Explore</a><a href="/admin">Admin</a></nav>
+</div></header>
+
+<div class="wrap">
+ <section class="hero">
+ <h1>Original seamless patterns, ready to license.</h1>
+ <p>A curated marketplace of owned, settlement-passed designs — every tile is truly seamless. Preview any pattern on a wall before you license it.</p>
+ </section>
+
+ <div class="shell">
+ <aside class="filters" id="filters"><!-- facets injected --></aside>
+
+ <main>
+ <div class="bar">
+ <span class="count" id="count">…</span>
+ <div><label for="sort">Sort</label>
+ <select id="sort" onchange="applyControls()">
+ <option value="newest">Newest</option>
+ <option value="color">Color</option>
+ <option value="style">Style</option>
+ <option value="sku_az">SKU A→Z</option>
+ <option value="title_az">Title A→Z</option>
+ <option value="price_up">Price ↑</option>
+ <option value="price_dn">Price ↓</option>
+ </select>
+ </div>
+ <div><label for="density">Density</label>
+ <input id="density" type="range" min="2" max="6" step="1" value="4" oninput="setDensity(this.value)">
+ </div>
+ </div>
+ <div class="grid" id="grid"></div>
+ <div class="empty" id="empty" hidden>No patterns match those filters. <a href="#" onclick="clearAll();return false">Clear filters</a></div>
+ </main>
+ </div>
+</div>
+<footer><div class="wrap">Pattern Design Lab · owned, settlement-passed originals only · local preview build</div></footer>
+
+<script>
+const state = { q:'', sort:'newest', style:'', colorway:'', motif:'', technique:'', seamless:'', pmin:'', pmax:'' };
+
+// ---- persisted controls (localStorage per DW spec) ----
+function setDensity(v){ document.documentElement.style.setProperty('--cols', v); localStorage.setItem('pdl.density', v); const el=document.getElementById('density'); if(el) el.value=v; }
+function loadControls(){
+ const d = localStorage.getItem('pdl.density'); if(d) setDensity(d);
+ const s = localStorage.getItem('pdl.sort'); if(s){ state.sort=s; document.getElementById('sort').value=s; }
+}
+function applyControls(){ state.sort=document.getElementById('sort').value; localStorage.setItem('pdl.sort', state.sort); load(); }
+
+function doSearch(e){ e.preventDefault(); state.q=document.getElementById('q').value.trim(); load(); return false; }
+function clearAll(){ Object.assign(state,{q:'',style:'',colorway:'',motif:'',technique:'',seamless:'',pmin:'',pmax:''}); document.getElementById('q').value=''; load(); }
+
+function fmtPrice(lo,hi){ if(lo==null) return 'Inquire'; return hi&&hi!=lo ? '$'+lo+'–$'+hi : '$'+lo; }
+
+async function load(){
+ const p = new URLSearchParams();
+ for(const k of ['q','sort','style','colorway','motif','technique','seamless','pmin','pmax']) if(state[k]) p.set(k,state[k]);
+ const r = await fetch('/api/designs?'+p.toString()); const j = await r.json();
+ const grid = document.getElementById('grid'); grid.innerHTML='';
+ document.getElementById('count').textContent = j.count + (j.count===1?' pattern':' patterns');
+ document.getElementById('empty').hidden = j.count>0;
+ for(const d of j.designs){
+ const el = document.createElement('a'); el.className='card'; el.href='/design/'+d.slug;
+ el.innerHTML = `
+ <div class="thumb"><div class="tile" style="background-image:url('${d.img}')"></div>
+ ${d.seamless?'<span class="badge">Seamless</span>':'<span class="badge">Mural</span>'}</div>
+ <div class="cardbody">
+ <h3>${esc(d.title)}</h3>
+ <div class="meta"><span class="swatch" style="background:${d.dominant_hex||'#ccc'}"></span>
+ <span>${esc(d.style||'')}</span><span>·</span><span>${esc(d.colorway||'')}</span></div>
+ <div class="byline">by ${esc(d.designer_name)}</div>
+ <div class="price">${fmtPrice(d.price_min,d.price_max)}</div>
+ </div>`;
+ grid.appendChild(el);
+ }
+ renderFacets();
+}
+
+function esc(s){ return (s==null?'':String(s)).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
+
+let FACETS=null;
+async function loadFacets(){ FACETS = await (await fetch('/api/facets')).json(); renderFacets(); }
+function facetGroup(title,key,rows){
+ return `<div class="fgroup"><h4>${title}</h4>`+
+ rows.map(o=>`<div class="opt ${state[key]===String(o.value)?'active':''}" onclick="toggleFacet('${key}','${esc(String(o.value)).replace(/'/g,"\\'")}')">
+ <span>${esc(o.value)}</span><span class="n">${o.n}</span></div>`).join('')+`</div>`;
+}
+function renderFacets(){
+ if(!FACETS) return;
+ const f = document.getElementById('filters');
+ const p = FACETS.price||{};
+ f.innerHTML =
+ facetGroup('Style','style',FACETS.style)+
+ facetGroup('Colorway','colorway',FACETS.colorway)+
+ facetGroup('Motif','motif',FACETS.motif)+
+ facetGroup('Technique','technique',FACETS.technique)+
+ `<div class="fgroup"><h4>Seamless</h4><div class="seg">
+ <button class="${state.seamless===''?'active':''}" onclick="setSeamless('')">All</button>
+ <button class="${state.seamless==='yes'?'active':''}" onclick="setSeamless('yes')">Yes</button>
+ <button class="${state.seamless==='no'?'active':''}" onclick="setSeamless('no')">No</button></div></div>`+
+ `<div class="fgroup"><h4>Price (from)</h4><div class="pricewrap">
+ <input id="pmin" type="number" placeholder="${p.lo??''}" value="${state.pmin}" oninput="state.pmin=this.value">
+ <span>–</span>
+ <input id="pmax" type="number" placeholder="${p.hi??''}" value="${state.pmax}" oninput="state.pmax=this.value"></div>
+ <button class="clearall" onclick="load()">Apply price</button></div>`+
+ `<button class="clearall" onclick="clearAll()">Clear all filters</button>`;
+}
+function toggleFacet(key,val){ state[key] = state[key]===val ? '' : val; load(); }
+function setSeamless(v){ state.seamless=v; load(); }
+
+loadControls(); loadFacets(); load();
+</script>
+</body>
+</html>
diff --git a/public/styles.css b/public/styles.css
new file mode 100644
index 0000000..fbe2d12
--- /dev/null
+++ b/public/styles.css
@@ -0,0 +1,126 @@
+:root{
+ --bg:#faf9f7; --panel:#ffffff; --ink:#1a1a1a; --muted:#6b6b6b; --line:#e7e4df;
+ --accent:#0e7c66; --accent-ink:#0a5c4b; --chip:#f1efeb; --shadow:0 1px 2px rgba(0,0,0,.04),0 8px 24px rgba(0,0,0,.06);
+ --cols:4; --radius:14px;
+}
+*{box-sizing:border-box}
+html,body{margin:0;padding:0}
+body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Helvetica,Arial,sans-serif;
+ background:var(--bg);color:var(--ink);-webkit-font-smoothing:antialiased;line-height:1.45}
+a{color:inherit;text-decoration:none}
+img{display:block;max-width:100%}
+.wrap{max-width:1320px;margin:0 auto;padding:0 20px}
+
+/* header */
+header.top{position:sticky;top:0;z-index:20;background:rgba(250,249,247,.86);backdrop-filter:saturate(1.2) blur(10px);border-bottom:1px solid var(--line)}
+.top .wrap{display:flex;align-items:center;gap:18px;height:64px}
+.brand{display:flex;align-items:baseline;gap:8px;font-weight:800;letter-spacing:-.02em;font-size:20px}
+.brand .dot{color:var(--accent)}
+.brand small{font-weight:500;color:var(--muted);font-size:12px;letter-spacing:.02em}
+.searchbox{flex:1;max-width:520px;position:relative}
+.searchbox input{width:100%;height:42px;border:1px solid var(--line);border-radius:999px;padding:0 44px 0 18px;
+ font-size:15px;background:var(--panel);outline:none}
+.searchbox input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(14,124,102,.12)}
+.searchbox .go{position:absolute;right:6px;top:6px;height:30px;width:30px;border:0;border-radius:999px;background:var(--accent);color:#fff;cursor:pointer;font-size:15px}
+.navlinks{display:flex;gap:16px;font-size:14px;color:var(--muted)}
+.navlinks a:hover{color:var(--ink)}
+
+/* hero */
+.hero{padding:44px 0 20px}
+.hero h1{font-size:clamp(28px,4.4vw,46px);letter-spacing:-.03em;margin:0 0 8px;font-weight:800}
+.hero p{color:var(--muted);font-size:17px;margin:0;max-width:640px}
+
+/* layout */
+.shell{display:grid;grid-template-columns:248px 1fr;gap:28px;padding:8px 0 60px}
+.filters{position:sticky;top:80px;align-self:start;max-height:calc(100vh - 100px);overflow:auto;padding-right:4px}
+.fgroup{border-top:1px solid var(--line);padding:14px 0}
+.fgroup:first-child{border-top:0}
+.fgroup h4{margin:0 0 10px;font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}
+.opt{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:5px 8px;border-radius:8px;cursor:pointer;font-size:14px}
+.opt:hover{background:var(--chip)}
+.opt.active{background:var(--accent);color:#fff}
+.opt .n{font-size:12px;color:var(--muted)}
+.opt.active .n{color:rgba(255,255,255,.8)}
+.pricewrap{display:flex;gap:8px;align-items:center}
+.pricewrap input{width:100%;height:34px;border:1px solid var(--line);border-radius:8px;padding:0 8px;font-size:13px}
+.seg{display:flex;gap:6px}
+.seg button{flex:1;height:32px;border:1px solid var(--line);background:var(--panel);border-radius:8px;cursor:pointer;font-size:13px}
+.seg button.active{background:var(--accent);color:#fff;border-color:var(--accent)}
+.clearall{margin-top:12px;width:100%;height:36px;border:1px solid var(--line);background:var(--panel);border-radius:8px;cursor:pointer;font-size:13px}
+
+/* controls bar (sort + density — DW spec) */
+.bar{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:18px}
+.bar .count{font-size:14px;color:var(--muted);margin-right:auto}
+.bar label{font-size:12px;color:var(--muted);margin-right:6px}
+.bar select{height:38px;border:1px solid var(--line);border-radius:10px;background:var(--panel);padding:0 30px 0 12px;font-size:14px;cursor:pointer}
+.bar input[type=range]{accent-color:var(--accent);width:130px}
+
+/* grid + cards */
+.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:18px}
+.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);overflow:hidden;box-shadow:var(--shadow);transition:transform .15s ease,box-shadow .15s ease}
+.card:hover{transform:translateY(-3px);box-shadow:0 2px 4px rgba(0,0,0,.05),0 16px 40px rgba(0,0,0,.10)}
+.thumb{aspect-ratio:1/1;position:relative;background-size:cover;background-position:center}
+.thumb .tile{position:absolute;inset:0;background-repeat:repeat;background-size:33% 33%}
+.badge{position:absolute;top:10px;left:10px;background:rgba(26,26,26,.78);color:#fff;font-size:11px;padding:3px 8px;border-radius:999px;letter-spacing:.02em}
+.cardbody{padding:12px 14px 14px}
+.cardbody h3{margin:0 0 4px;font-size:14.5px;letter-spacing:-.01em;line-height:1.3}
+.meta{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--muted)}
+.swatch{width:12px;height:12px;border-radius:3px;border:1px solid rgba(0,0,0,.12);flex:none}
+.price{margin-top:8px;font-size:13px;color:var(--accent-ink);font-weight:600}
+.byline{font-size:12px;color:var(--muted);margin-top:2px}
+.byline a:hover{color:var(--accent)}
+.empty{padding:60px 0;text-align:center;color:var(--muted)}
+
+/* detail page */
+.detail{display:grid;grid-template-columns:1.1fr .9fr;gap:36px;padding:28px 0 70px}
+.preview{position:sticky;top:80px;align-self:start}
+.preview .big{width:100%;aspect-ratio:1/1;border-radius:var(--radius);border:1px solid var(--line);background-size:cover;background-position:center;box-shadow:var(--shadow)}
+.wallwrap{margin-top:16px}
+.wall{position:relative;height:300px;border-radius:var(--radius);overflow:hidden;border:1px solid var(--line);background:#efe9e0}
+.wall .paper{position:absolute;inset:0;background-repeat:repeat}
+.wall .floor{position:absolute;left:0;right:0;bottom:0;height:64px;background:linear-gradient(#c9b7a0,#b09a80)}
+.wall .sofa{position:absolute;left:8%;right:8%;bottom:40px;height:96px;background:#3b3a38;border-radius:16px 16px 6px 6px;box-shadow:0 12px 24px rgba(0,0,0,.25)}
+.wall .lamp{position:absolute;right:12%;bottom:40px;width:6px;height:150px;background:#2b2b2b}
+.wall .shade{position:absolute;right:9%;bottom:180px;width:60px;height:44px;background:#f6efe2;border-radius:6px 6px 0 0}
+.scalebar{display:flex;gap:8px;align-items:center;margin-top:10px}
+.scalebar input{flex:1;accent-color:var(--accent)}
+.dtitle{font-size:30px;letter-spacing:-.02em;margin:0 0 4px}
+.dsub{color:var(--muted);margin:0 0 18px}
+.specs{display:grid;grid-template-columns:1fr 1fr;gap:10px 20px;margin:18px 0;border-top:1px solid var(--line);padding-top:18px}
+.specs .k{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--muted)}
+.specs .v{font-size:15px;margin-top:2px}
+.tiers{display:flex;flex-direction:column;gap:10px;margin:20px 0}
+.tier{display:flex;align-items:center;justify-content:space-between;border:1px solid var(--line);border-radius:12px;padding:14px 16px;cursor:pointer;background:var(--panel)}
+.tier.sel{border-color:var(--accent);box-shadow:0 0 0 3px rgba(14,124,102,.12)}
+.tier .tp{font-weight:700}
+.buy{width:100%;height:50px;border:0;border-radius:12px;background:var(--accent);color:#fff;font-size:16px;font-weight:600;cursor:pointer}
+.buy:hover{background:var(--accent-ink)}
+.note{font-size:12.5px;color:var(--muted);margin-top:10px}
+.tagrow{display:flex;flex-wrap:wrap;gap:6px;margin-top:14px}
+.tag{background:var(--chip);border-radius:999px;padding:4px 10px;font-size:12px;color:var(--muted)}
+
+/* designer header */
+.dhero{display:flex;gap:18px;align-items:center;padding:30px 0 10px}
+.dhero .av{width:76px;height:76px;border-radius:50%;background-size:cover;background-position:center;border:2px solid var(--line);flex:none}
+.dhero h1{margin:0;font-size:28px;letter-spacing:-.02em}
+.dhero p{margin:4px 0 0;color:var(--muted);max-width:640px}
+
+/* admin */
+.adminbar{display:flex;align-items:center;gap:12px;padding:20px 0}
+.when{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;color:var(--muted);background:var(--chip);border-radius:999px;padding:3px 9px}
+.admgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px;padding-bottom:60px}
+.acard{background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden;box-shadow:var(--shadow)}
+.acard .t{height:150px;background-size:cover;background-position:center}
+.acard .b{padding:10px 12px 12px}
+.acard h3{margin:0 0 6px;font-size:13.5px}
+.acard .row{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px}
+
+footer{border-top:1px solid var(--line);padding:24px 0;color:var(--muted);font-size:13px}
+
+@media(max-width:900px){
+ .shell{grid-template-columns:1fr}
+ .filters{position:static;max-height:none;border:1px solid var(--line);border-radius:12px;padding:12px;margin-bottom:8px}
+ .detail{grid-template-columns:1fr}
+ .preview{position:static}
+ .navlinks{display:none}
+}
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..37fb7c2
--- /dev/null
+++ b/server.js
@@ -0,0 +1,161 @@
+// patterndesignlab.com — modern seamless-pattern licensing marketplace (a nicer Patternbank).
+// OWNED content only. LOCAL + Basic-Auth until Steve flips it public. Stripe stays TEST-mode.
+// The patternbank-archive scrape is compliance-LOCKED and is NEVER read/served/referenced here.
+const express = require('express');
+const path = require('path');
+const { Pool } = require('pg');
+try { require('dotenv').config(); } catch {}
+
+const app = express();
+const PORT = process.env.PORT || 9781;
+const USER = process.env.PDL_USER || 'admin';
+const PASS = process.env.PDL_PASS || 'DW2024!';
+const PUBLIC = (process.env.PUBLIC || '0') === '1'; // Steve's switch — never flipped here
+
+const pool = new Pool({
+ host: process.env.PGHOST || '127.0.0.1',
+ user: process.env.PGUSER || 'dw_admin',
+ database: process.env.PGDATABASE || 'patterndesignlab',
+ password: process.env.PGPASSWORD || undefined,
+});
+
+// ---- Basic Auth until PUBLIC=1 (healthz always open for probes) ----
+app.use((req, res, next) => {
+ if (req.path === '/api/healthz') return next();
+ if (PUBLIC) return next();
+ const h = req.headers.authorization || '';
+ const [u, p] = Buffer.from(h.split(' ')[1] || '', 'base64').toString().split(':');
+ if (u === USER && p === PASS) return next();
+ res.set('WWW-Authenticate', 'Basic realm="patterndesignlab"').status(401).send('auth required');
+});
+app.use(express.json());
+app.use(express.static(path.join(__dirname, 'public')));
+
+// ---- Stripe TEST-mode only; sk_live_ is REFUSED (money is Steve-gated) [lifted verbatim] ----
+let stripe = null;
+const SK = process.env.STRIPE_SECRET_KEY || '';
+if (SK.startsWith('sk_test_')) { try { stripe = require('stripe')(SK); } catch {} }
+else if (SK.startsWith('sk_live_')) console.error('REFUSING sk_live_ key — live charges are Steve-gated. Money routes disabled.');
+
+app.get('/api/config', (req, res) => res.json({
+ checkoutReady: !!stripe, mode: stripe ? 'test' : 'none', public: PUBLIC,
+ note: stripe ? 'TEST MODE — no real charges' : 'test keys not yet installed — inquiries only',
+}));
+
+// ---- search + faceted browse ----
+const SORTS = {
+ newest: 'x.created_at DESC',
+ color: 'x.dominant_hex ASC, x.title ASC',
+ style: 'x.style ASC, x.title ASC',
+ sku_az: 'x.id ASC',
+ title_az: 'x.title ASC',
+ price_up: 'x.price_min ASC NULLS LAST, x.title ASC',
+ price_dn: 'x.price_min DESC NULLS LAST, x.title ASC',
+};
+function buildWhere(q, params) {
+ const w = [];
+ if (q.q) { params.push(q.q); w.push(`x.search_tsv @@ plainto_tsquery('english', $${params.length})`); }
+ for (const [key, col] of [['style','style'],['colorway','colorway'],['motif','motif'],['technique','technique']]) {
+ if (q[key]) { params.push(q[key]); w.push(`x.${col} = $${params.length}`); }
+ }
+ if (q.seamless === 'yes') w.push('x.seamless = true');
+ if (q.seamless === 'no') w.push('x.seamless = false');
+ if (q.pmin) { params.push(Number(q.pmin)); w.push(`x.price_min >= $${params.length}`); }
+ if (q.pmax) { params.push(Number(q.pmax)); w.push(`x.price_min <= $${params.length}`); }
+ if (q.designer) { params.push(q.designer); w.push(`d.slug = $${params.length}`); }
+ return w.length ? 'WHERE ' + w.join(' AND ') : '';
+}
+
+app.get('/api/designs', async (req, res) => {
+ try {
+ const params = [];
+ const where = buildWhere(req.query, params);
+ const order = SORTS[req.query.sort] || SORTS.newest;
+ const sql = `SELECT x.id,x.slug,x.title,x.style,x.motif,x.colorway,x.technique,x.seamless,
+ x.dominant_hex,x.tags,x.img,x.price_min,x.price_max,x.kind,x.created_at,
+ d.name AS designer_name, d.slug AS designer_slug
+ FROM designs x JOIN designers d ON d.id=x.designer_id
+ ${where} ORDER BY ${order}`;
+ const r = await pool.query(sql, params);
+ res.json({ count: r.rowCount, designs: r.rows });
+ } catch (e) { console.error(e); res.status(500).json({ error: 'query failed' }); }
+});
+
+app.get('/api/facets', async (req, res) => {
+ try {
+ const facet = async (col) => (await pool.query(
+ `SELECT ${col} AS value, count(*)::int AS n FROM designs WHERE ${col} IS NOT NULL GROUP BY ${col} ORDER BY n DESC, value ASC`)).rows;
+ const price = (await pool.query('SELECT min(price_min)::float lo, max(price_min)::float hi FROM designs')).rows[0];
+ res.json({
+ style: await facet('style'), colorway: await facet('colorway'),
+ motif: await facet('motif'), technique: await facet('technique'), price,
+ });
+ } catch (e) { console.error(e); res.status(500).json({ error: 'facets failed' }); }
+});
+
+app.get('/api/designs/:id', async (req, res) => {
+ try {
+ const r = await pool.query(
+ `SELECT x.*, d.name AS designer_name, d.slug AS designer_slug, d.country AS designer_country
+ FROM designs x JOIN designers d ON d.id=x.designer_id WHERE x.id=$1 OR x.slug=$1`, [req.params.id]);
+ if (!r.rowCount) return res.status(404).json({ error: 'not found' });
+ res.json(r.rows[0]);
+ } catch (e) { console.error(e); res.status(500).json({ error: 'query failed' }); }
+});
+
+app.get('/api/designers/:slug', async (req, res) => {
+ try {
+ const d = await pool.query('SELECT * FROM designers WHERE slug=$1', [req.params.slug]);
+ if (!d.rowCount) return res.status(404).json({ error: 'not found' });
+ const items = await pool.query(
+ `SELECT x.id,x.slug,x.title,x.style,x.colorway,x.dominant_hex,x.img,x.price_min,x.seamless,x.created_at
+ FROM designs x WHERE x.designer_id=$1 ORDER BY x.created_at DESC`, [d.rows[0].id]);
+ res.json({ designer: d.rows[0], designs: items.rows, count: items.rowCount });
+ } catch (e) { console.error(e); res.status(500).json({ error: 'query failed' }); }
+});
+
+// ---- license checkout — Stripe TEST-mode only; without keys it records an inquiry ----
+app.post('/api/license/checkout', async (req, res) => {
+ const { designId, tier, email } = req.body || {};
+ try {
+ const r = await pool.query('SELECT title, license_tiers FROM designs WHERE id=$1 OR slug=$1', [String(designId)]);
+ if (!r.rowCount) return res.status(404).json({ error: 'unknown design' });
+ const design = r.rows[0];
+ const t = (design.license_tiers || []).find(x => x.tier === tier);
+ if (!t) return res.status(400).json({ error: 'unknown tier' });
+ await pool.query('INSERT INTO license_events(design_id,tier,email,kind) VALUES($1,$2,$3,$4)',
+ [String(designId), tier, (email || '').slice(0, 254), stripe ? 'checkout_start' : 'inquiry']);
+ if (!stripe) return res.json({ ok: true, mode: 'inquiry', note: 'recorded — checkout opens when Stripe test keys land' });
+ const session = await stripe.checkout.sessions.create({
+ mode: 'payment',
+ line_items: [{ price_data: { currency: 'usd', unit_amount: Math.round((t.priceUsd || 149) * 100),
+ product_data: { name: `${design.title} — ${t.label || tier} license` } }, quantity: 1 }],
+ success_url: `http://127.0.0.1:${PORT}/?licensed=${designId}`, cancel_url: `http://127.0.0.1:${PORT}/`,
+ metadata: { designId: String(designId), tier },
+ });
+ res.json({ ok: true, mode: 'test', url: session.url });
+ } catch (e) { console.error('checkout error', e.message); res.status(502).json({ error: 'checkout unavailable', detail: e.message }); }
+});
+
+// ---- admin design list (basic-auth already applied above) ----
+app.get('/api/admin/designs', async (req, res) => {
+ try {
+ const r = await pool.query(
+ `SELECT x.id,x.title,x.style,x.colorway,x.motif,x.technique,x.seamless,x.dominant_hex,
+ x.img,x.price_min,x.price_max,x.pricing_status,x.created_at,d.name AS designer_name
+ FROM designs x JOIN designers d ON d.id=x.designer_id ORDER BY x.created_at DESC`);
+ res.json({ count: r.rowCount, designs: r.rows });
+ } catch (e) { console.error(e); res.status(500).json({ error: 'query failed' }); }
+});
+
+// ---- pretty routes ----
+app.get('/design/:id', (req, res) => res.sendFile(path.join(__dirname, 'public', 'design.html')));
+app.get('/designer/:slug', (req, res) => res.sendFile(path.join(__dirname, 'public', 'designer.html')));
+app.get('/admin', (req, res) => res.sendFile(path.join(__dirname, 'public', 'admin.html')));
+
+app.get('/api/healthz', async (req, res) => {
+ try { const r = await pool.query('SELECT count(*)::int n FROM designs'); res.json({ ok: true, service: 'patterndesignlab', designs: r.rows[0].n, public: PUBLIC, stripe: stripe ? 'test' : 'none' }); }
+ catch (e) { res.status(500).json({ ok: false, error: e.message }); }
+});
+
+app.listen(PORT, () => console.log(`patterndesignlab on :${PORT} · public=${PUBLIC} · stripe=${stripe ? 'test' : 'none'}`));
← 989cb8c postgres schema + seed 38 owned designs across 3 designer pr
·
back to Patterndesignlab
·
refine style facet (stripe→Stripe not Coastal); favicon 204 c083b5c →