[object Object]

← back to Astek Landing

Add /curate — Astek -> Phillipe Romano private-label selection page: texture-only tiles (grid slider 1-20), bulk multi-select (click / shift-range / select-visible), server-persisted picks (data/selection.json via /api/selection), show-selected-only, CSV export of candidates; '✓ Select for PR' entry in catalog header. Selection only — no Shopify writes (PR push stays Steve-gated). E2E: 8-pick select+shift-range persisted across reload, 0 errors

4defe607786f1bd8a3c745628c66c8dbe4913643 · 2026-07-02 10:11:25 -0700 · Steve

Files touched

Diff

commit 4defe607786f1bd8a3c745628c66c8dbe4913643
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 2 10:11:25 2026 -0700

    Add /curate — Astek -> Phillipe Romano private-label selection page: texture-only tiles (grid slider 1-20), bulk multi-select (click / shift-range / select-visible), server-persisted picks (data/selection.json via /api/selection), show-selected-only, CSV export of candidates; '✓ Select for PR' entry in catalog header. Selection only — no Shopify writes (PR push stays Steve-gated). E2E: 8-pick select+shift-range persisted across reload, 0 errors
---
 public/curate.html | 160 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 public/index.html  |   1 +
 server.js          |  36 ++++++++++--
 3 files changed, 192 insertions(+), 5 deletions(-)

diff --git a/public/curate.html b/public/curate.html
new file mode 100644
index 0000000..4126fd7
--- /dev/null
+++ b/public/curate.html
@@ -0,0 +1,160 @@
+<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Select · Astek → Phillipe Romano</title>
+<style>
+:root{--cols:8;--bg:#0c0d12;--panel:#15171f;--ink:#eef0f5;--mut:#9aa0b0;--acc:#5eead4;--line:#262a36;--new:#f59e0b}
+*{box-sizing:border-box}
+body{margin:0;background:var(--bg);color:var(--ink);font:13px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
+header{position:sticky;top:0;z-index:9;background:linear-gradient(180deg,#0c0d12,#0c0d12e8);backdrop-filter:blur(8px);
+ border-bottom:1px solid var(--line);padding:10px 14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap}
+h1{font-size:14px;margin:0;font-weight:700}
+h1 .pl{color:var(--acc)}
+.pill{background:var(--new);color:#1a1205;font-weight:700;border-radius:999px;padding:2px 10px;font-size:12px;font-variant-numeric:tabular-nums}
+.muted{color:var(--mut)}
+select,input[type=text]{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12.5px}
+input[type=range]{accent-color:var(--acc);width:150px}
+.btn{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:5px 11px;cursor:pointer;font-size:12.5px;text-decoration:none}
+.btn:hover{border-color:var(--acc)}
+.btn.on{outline:1px solid var(--acc);color:var(--acc)}
+.ctl{display:flex;align-items:center;gap:6px}
+/* texture-only grid — the image IS the tile */
+.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:6px;padding:8px 10px 70px}
+.tile{position:relative;aspect-ratio:1/1;background:#0a0b0f;border:1px solid var(--line);border-radius:2px;overflow:hidden;cursor:pointer;user-select:none}
+.tile img{width:100%;height:100%;object-fit:cover;display:block;pointer-events:none}
+.tile:hover{border-color:#414a5e}
+.tile.sel{border-color:var(--acc);box-shadow:inset 0 0 0 2px var(--acc)}
+.tile.sel::after{content:'✓';position:absolute;top:5px;right:5px;width:20px;height:20px;border-radius:50%;
+ background:var(--acc);color:#04251e;font-weight:800;font-size:13px;display:flex;align-items:center;justify-content:center}
+.tile .tip{position:absolute;left:0;right:0;bottom:0;background:#000b;color:#cbd5e1;font-size:10px;padding:2px 6px;
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis;opacity:0;transition:opacity .12s}
+.tile:hover .tip{opacity:1}
+#more{display:block;margin:8px auto 70px;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:9px 18px;cursor:pointer}
+#empty{padding:60px;text-align:center;color:var(--mut)}
+/* selection tray */
+#tray{position:fixed;left:0;right:0;bottom:0;z-index:20;display:flex;gap:10px;align-items:center;flex-wrap:wrap;
+ background:#0e0f15f2;border-top:1px solid var(--line);padding:9px 14px;backdrop-filter:blur(8px)}
+#tray .cnt{font-weight:700;color:var(--acc);font-variant-numeric:tabular-nums}
+#save{font-size:11px;color:var(--mut)}
+</style></head>
+<body>
+<header>
+  <h1>Astek → <span class="pl">Phillipe Romano</span> · Select</h1>
+  <span class="pill" id="total">…</span>
+  <span class="muted" id="meta"></span>
+  <div class="ctl"><input type="text" id="q" placeholder="search pattern / sku / color…"></div>
+  <div class="ctl"><select id="book"><option value="">All books</option></select></div>
+  <div class="ctl"><select id="color"><option value="">All colors</option></select></div>
+  <div class="ctl"><label class="muted">Grid</label>
+    <input type="range" id="density" min="1" max="20" step="1"><span class="muted" id="denv"></span></div>
+  <a class="btn" href="/">← Catalog</a>
+</header>
+<div class="grid" id="grid"></div>
+<button id="more" style="display:none">Load more</button>
+<div id="empty" style="display:none">No products match.</div>
+<div id="tray">
+  <span><span class="cnt" id="selcount">0</span> selected</span>
+  <button class="btn" id="selvis">☑ Select visible</button>
+  <button class="btn" id="unselvis">☐ Unselect visible</button>
+  <button class="btn" id="showsel">Show selected only</button>
+  <button class="btn" id="clear">✕ Clear all</button>
+  <button class="btn" id="csv">⬇ Export CSV</button>
+  <span id="save" class="muted"></span>
+  <span class="muted" style="margin-left:auto">click = select · shift-click = range · ⌘-click = open product</span>
+</div>
+<script>
+const $=s=>document.querySelector(s);
+const grid=$('#grid');
+let ALL=[], VIEW=[], shown=0, lastIdx=null, showSelOnly=false;
+const PAGE=400;
+const SEL=new Set();          // dw_sku set — the Phillipe Romano candidate list
+const BY_SKU={};              // dw_sku -> product
+function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
+const dens=localStorage.getItem('astek.cur.density')||'8';
+$('#density').value=dens; $('#denv').textContent=dens;
+document.documentElement.style.setProperty('--cols',dens);
+$('#density').oninput=()=>{const v=$('#density').value;document.documentElement.style.setProperty('--cols',v);
+ $('#denv').textContent=v;localStorage.setItem('astek.cur.density',v);};
+
+function tile(p,idx){
+  const on=SEL.has(p.dw_sku);
+  return `<div class="tile${on?' sel':''}" data-sku="${esc(p.dw_sku)}" data-idx="${idx}" data-href="/product/${encodeURIComponent(p.handle)}">
+    <img loading="lazy" src="${esc(p.swatch)}" alt="${esc(p.display_name||p.sku||'')}">
+    <span class="tip">${esc(p.series||p.display_name||'')}${p.color?' · '+esc(p.color):''} · ${esc(p.sku||'')}</span>
+  </div>`;
+}
+grid.addEventListener('click',e=>{
+  const t=e.target.closest('.tile'); if(!t)return;
+  if(e.metaKey||e.ctrlKey){window.open(t.dataset.href,'_blank');return;}
+  const idx=Number(t.dataset.idx);
+  if(e.shiftKey&&lastIdx!=null){
+    const [a,b]=[Math.min(lastIdx,idx),Math.max(lastIdx,idx)];
+    const turnOn=!SEL.has(t.dataset.sku);
+    for(let i=a;i<=b;i++){const p=VIEW[i];if(!p)continue;turnOn?SEL.add(p.dw_sku):SEL.delete(p.dw_sku);}
+    repaint();
+  }else{
+    SEL.has(t.dataset.sku)?SEL.delete(t.dataset.sku):SEL.add(t.dataset.sku);
+    t.classList.toggle('sel',SEL.has(t.dataset.sku));
+  }
+  lastIdx=idx; syncCount(); saveSoon();
+});
+function repaint(){document.querySelectorAll('.tile').forEach(t=>t.classList.toggle('sel',SEL.has(t.dataset.sku)));}
+function syncCount(){$('#selcount').textContent=SEL.size.toLocaleString();}
+let st;
+function saveSoon(){clearTimeout(st);st=setTimeout(saveNow,700);}
+async function saveNow(){
+  try{
+    const r=await fetch('/api/selection',{method:'POST',headers:{'Content-Type':'application/json'},
+      body:JSON.stringify({skus:[...SEL]})}).then(r=>r.json());
+    $('#save').textContent=r.ok?('saved '+new Date().toLocaleTimeString()):'save failed';
+  }catch{$('#save').textContent='save failed';}
+}
+function applyFilters(){
+  const bk=$('#book').value, cl=$('#color').value, q=$('#q').value.trim().toLowerCase();
+  VIEW=ALL.filter(p=>
+    (!showSelOnly||SEL.has(p.dw_sku))&&
+    (!bk||p.book===bk)&&(!cl||p.color_bucket===cl)&&
+    (!q||[p.display_name,p.series,p.sku,p.dw_sku,p.color,p.book].some(v=>v&&String(v).toLowerCase().includes(q))));
+  shown=0; lastIdx=null; grid.innerHTML=''; renderMore();
+  $('#total').textContent=VIEW.length.toLocaleString()+' designs';
+  $('#empty').style.display=VIEW.length?'none':'block';
+}
+function renderMore(){
+  const slice=VIEW.slice(shown,shown+PAGE);
+  grid.insertAdjacentHTML('beforeend',slice.map((p,i)=>tile(p,shown+i)).join(''));
+  shown+=slice.length;
+  $('#meta').textContent=`${shown.toLocaleString()} of ${VIEW.length.toLocaleString()} shown`;
+  $('#more').style.display=shown<VIEW.length?'block':'none';
+}
+$('#more').onclick=renderMore;
+addEventListener('scroll',()=>{if(shown<VIEW.length&&innerHeight+scrollY>=document.body.offsetHeight-600)renderMore();});
+$('#book').onchange=applyFilters; $('#color').onchange=applyFilters;
+let t; $('#q').oninput=()=>{clearTimeout(t);t=setTimeout(applyFilters,300);};
+$('#selvis').onclick=()=>{VIEW.slice(0,shown).forEach(p=>SEL.add(p.dw_sku));repaint();syncCount();saveSoon();};
+$('#unselvis').onclick=()=>{VIEW.slice(0,shown).forEach(p=>SEL.delete(p.dw_sku));repaint();syncCount();saveSoon();};
+$('#showsel').onclick=()=>{showSelOnly=!showSelOnly;$('#showsel').classList.toggle('on',showSelOnly);applyFilters();};
+$('#clear').onclick=()=>{if(!SEL.size||confirm('Clear all '+SEL.size+' selected?')){SEL.clear();repaint();syncCount();saveSoon();applyFilters();}};
+$('#csv').onclick=()=>{
+  const rows=[['dw_sku','vendor_sku','pattern','color','book','handle','swatch_url']];
+  for(const s of SEL){const p=BY_SKU[s];if(!p)continue;
+    rows.push([p.dw_sku,p.sku,p.series||p.display_name,p.color||'',p.book||'',p.handle,p.swatch||'']);}
+  const csv=rows.map(r=>r.map(v=>'"'+String(v??'').replace(/"/g,'""')+'"').join(',')).join('\n');
+  const a=document.createElement('a');
+  a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv'}));
+  a.download='phillipe-romano-candidates-'+new Date().toISOString().slice(0,10)+'.csv';
+  a.click();
+};
+async function load(){
+  const [pd,fc,sel]=await Promise.all([
+    fetch('/api/products').then(r=>r.json()),
+    fetch('/api/facets').then(r=>r.json()),
+    fetch('/api/selection').then(r=>r.json())]);
+  ALL=pd.products; ALL.forEach(p=>BY_SKU[p.dw_sku]=p);
+  (sel.skus||[]).forEach(s=>SEL.add(s));
+  $('#book').innerHTML='<option value="">All books</option>'+(fc.books||[]).map(([b,n])=>`<option value="${esc(b)}">${esc(b)} (${n.toLocaleString()})</option>`).join('');
+  const colors={}; ALL.forEach(p=>{if(p.color_bucket)colors[p.color_bucket]=(colors[p.color_bucket]||0)+1;});
+  $('#color').innerHTML='<option value="">All colors</option>'+Object.entries(colors).sort((a,b)=>b[1]-a[1]).map(([c,n])=>`<option value="${esc(c)}">${esc(c)} (${n.toLocaleString()})</option>`).join('');
+  syncCount(); applyFilters();
+}
+load();
+</script>
+</body></html>
diff --git a/public/index.html b/public/index.html
index a5067f7..5d25ffb 100644
--- a/public/index.html
+++ b/public/index.html
@@ -126,6 +126,7 @@ body.listmode #listhead{display:grid}
   <div class="ctl"><label class="muted">Density</label>
     <input type="range" id="density" min="3" max="20" step="1"></div>
   <button class="btn" id="view">▤ List view</button>
+  <a class="btn" href="/curate" style="text-decoration:none" title="bulk-select candidates for the Phillipe Romano private label">✓ Select for PR</a>
   <!-- state holders for the sidebar facet rows (hidden; sidebar is the UI) -->
   <select id="book" hidden><option value="">All books</option></select>
   <select id="color" hidden><option value="">All colors</option></select>
diff --git a/server.js b/server.js
index f668ca2..e318ba8 100644
--- a/server.js
+++ b/server.js
@@ -26,7 +26,7 @@ app.use((req, res, next) => {
   return res.status(401).send('Authentication required.');
 });
 
-app.use(express.json({ limit: '256kb' }));
+app.use(express.json({ limit: '2mb' }));
 
 let SNAP = { products: [], facets: { total: 0, books: [], series: [], colors: [] } };
 let LIGHT = []; // grid-index payload — heavy PDP-only fields stripped
@@ -73,7 +73,9 @@ const CONFIG = {
   },
 };
 
-app.get('/api/config', (_req, res) => res.json(CONFIG));
+// vendorMeta (phone / our account # / discount / pricing model) rides in from
+// data/products.json — the 15-min refresh keeps it current from PG.
+app.get('/api/config', (_req, res) => res.json({ ...CONFIG, vendorMeta: SNAP.vendor || null }));
 // Refresh metadata — drives the corner countdown pill on the landing.
 app.get('/api/meta', (_req, res) => res.json({
   lastRefresh: LAST_REFRESH, intervalSec: REFRESH_INTERVAL_SEC,
@@ -107,19 +109,43 @@ app.get('/api/pairs/:handle', (req, res) => {
   res.json({ pairs: out });
 });
 
-// Memo-sample / inquiry — the internal CTA. No checkout. Logs to data/inquiries.jsonl.
+// Internal action requests — Request Memo / Check Stock / Get Price.
+// No checkout. Each logs to data/inquiries.jsonl with its type.
+const ACTION_MSG = {
+  memo: 'Memo request logged — purchasing will order the sample.',
+  stock: 'Stock check logged — purchasing will confirm availability with the vendor.',
+  price: 'Price request logged — purchasing will confirm current pricing with the vendor.',
+};
 app.post('/api/inquiry', (req, res) => {
   const { sku, name, email, note } = req.body || {};
+  const type = ['memo', 'stock', 'price'].includes(req.body && req.body.type) ? req.body.type : 'memo';
   if (!sku || !email) return res.status(400).json({ ok: false, error: 'sku and email required' });
-  const rec = { at: new Date().toISOString(), sku, name: name || '', email, note: note || '', ip: req.ip };
+  const rec = { at: new Date().toISOString(), type, sku, name: name || '', email, note: note || '', ip: req.ip };
   try {
     fs.appendFileSync(path.join(__dirname, 'data', 'inquiries.jsonl'), JSON.stringify(rec) + '\n');
   } catch (e) { return res.status(500).json({ ok: false, error: 'log failed' }); }
-  res.json({ ok: true, message: 'Memo-sample request received. A Designer Wallcoverings specialist will follow up.' });
+  res.json({ ok: true, message: ACTION_MSG[type] });
+});
+
+// ── Private-label curation (Astek → Phillipe Romano) ─────────────────────
+// Selection is the FIRST step only — picks persist server-side so the later
+// (Steve-gated) private-label push has a durable list. NO Shopify writes here.
+const SEL = path.join(__dirname, 'data', 'selection.json');
+app.get('/api/selection', (_req, res) => {
+  try { res.json(JSON.parse(fs.readFileSync(SEL, 'utf8'))); }
+  catch { res.json({ updated_at: null, count: 0, skus: [] }); }
+});
+app.post('/api/selection', (req, res) => {
+  const skus = Array.isArray(req.body?.skus) ? req.body.skus.filter(s => typeof s === 'string').slice(0, 20000) : null;
+  if (!skus) return res.status(400).json({ ok: false, error: 'skus[] required' });
+  const doc = { updated_at: new Date().toISOString(), label: 'Phillipe Romano candidates', count: skus.length, skus };
+  fs.writeFileSync(SEL, JSON.stringify(doc, null, 1));
+  res.json({ ok: true, count: skus.length });
 });
 
 app.use(express.static(path.join(__dirname, 'public')));
 app.get('/product/:handle', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'product.html')));
+app.get('/curate', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'curate.html')));
 
 const server = app.listen(PORT, () => {
   console.log(`Astek landing → http://127.0.0.1:${server.address().port}`);

← f35814a Contrarian FIX FIRST pass: kill nested-anchor DOM split (120  ·  back to Astek Landing  ·  Fix Safari blank /curate: creds-in-URL WebKit pages reject r 834fe7f →