[object Object]

← back to Wallco Ai

wallco.ai · sample basket — multi-design trade samples (mirrors /compare): + Sample button on PDP pro-tools (max 12, localStorage[wallco-basket]), View Basket gold pill, /basket route with stacked mini-cards + reused samples form + random-rail empty state + ?ids= share-link fallback, POST /api/basket/submit appending to data/sample-requests.jsonl (no email, no DB), header nav 'Basket' link with client-hydrated count chip · verified: /basket 200, /basket?ids=72,161 200, submit POST 200, /compare /samples /design/72 unchanged

542e977f04bf48bc17e413930e5ee6cd61434e42 · 2026-05-12 08:48:02 -0700 · Steve Abrams

Files touched

Diff

commit 542e977f04bf48bc17e413930e5ee6cd61434e42
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue May 12 08:48:02 2026 -0700

    wallco.ai · sample basket — multi-design trade samples (mirrors /compare): + Sample button on PDP pro-tools (max 12, localStorage[wallco-basket]), View Basket gold pill, /basket route with stacked mini-cards + reused samples form + random-rail empty state + ?ids= share-link fallback, POST /api/basket/submit appending to data/sample-requests.jsonl (no email, no DB), header nav 'Basket' link with client-hydrated count chip · verified: /basket 200, /basket?ids=72,161 200, submit POST 200, /compare /samples /design/72 unchanged
---
 .gitignore |   1 +
 server.js  | 257 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 257 insertions(+), 1 deletion(-)

diff --git a/.gitignore b/.gitignore
index 3d823c4..1130d58 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,4 @@ data/images/
 data/generated/
 data/moodboards.json
 data/reviews.json
+data/sample-requests.jsonl
diff --git a/server.js b/server.js
index e34101d..500bc58 100644
--- a/server.js
+++ b/server.js
@@ -812,6 +812,7 @@ function htmlHeader(active) {
     ['/designs', 'Designs'],
     ['/murals', 'Murals'],
     ['/samples', 'Samples'],
+    ['/basket', 'Basket'],
     ['/studio', 'Studio'],
     ['/play',   'Play'],
     ['/generator', 'Generator'],
@@ -827,7 +828,7 @@ function htmlHeader(active) {
 </header>
 <nav class="nav-panel" id="nav-panel" hidden>
   ${navLinks.map(([href, label]) =>
-    `<a href="${href}" class="${active === href ? 'active' : ''}">${label}</a>`
+    `<a href="${href}" class="${active === href ? 'active' : ''}"${href === '/basket' ? ' id="nav-basket-link"' : ''}>${label}${href === '/basket' ? '<span id="nav-basket-count" style="display:none;margin-left:6px;font:11px var(--sans,system-ui);background:var(--gold,#c9a14b);color:var(--accent,#0d0d0d);padding:1px 7px;border-radius:999px;vertical-align:middle"></span>' : ''}</a>`
   ).join('\n  ')}
   <button class="theme-toggle" id="theme-toggle" aria-label="Toggle dark/light mode">
     <svg class="sun-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
@@ -965,6 +966,19 @@ const HAMBURGER_JS = `
     closeOverlay();
   }
 })();
+</script>
+<script>
+// Header basket-count hydration — reads localStorage['wallco-basket'] and
+// writes the count chip into the nav. No count shown when basket is empty.
+(function(){
+  try {
+    var list = JSON.parse(localStorage.getItem('wallco-basket') || '[]');
+    if (!Array.isArray(list)) list = [];
+    var n = list.filter(function(x){ return typeof x === 'number' && x > 0; }).length;
+    var chip = document.getElementById('nav-basket-count');
+    if (chip && n > 0) { chip.textContent = n; chip.style.display = 'inline-block'; }
+  } catch(e) {}
+})();
 </script>`;
 
 // ── Hero rotation images (highest-saturation designs, sat > 0.4)
@@ -3428,6 +3442,12 @@ ${htmlHeader('/designs')}
         <a id="cta-view-compare" href="/compare" class="btn-outline" style="font:12px var(--sans);padding:7px 14px;text-decoration:none;display:none;align-items:center;gap:6px;background:var(--gold,#c9a14b);color:var(--accent,#0d0d0d);border-color:var(--gold,#c9a14b)" title="View your compare list">
           View Compare (<span id="cta-compare-count">0</span>)
         </a>
+        <button id="cta-add-basket" type="button" data-design-id="${design.id}" class="btn-outline" style="font:12px var(--sans);padding:7px 14px;cursor:pointer;display:inline-flex;align-items:center;gap:6px" title="Add to your sample basket (up to 12)">
+          <span aria-hidden="true">+</span> <span class="cta-add-basket-label">Sample</span>
+        </button>
+        <a id="cta-view-basket" href="/basket" class="btn-outline" style="font:12px var(--sans);padding:7px 14px;text-decoration:none;display:none;align-items:center;gap:6px;background:var(--gold,#c9a14b);color:var(--accent,#0d0d0d);border-color:var(--gold,#c9a14b)" title="View your sample basket">
+          View Basket (<span id="cta-basket-count">0</span>)
+        </a>
       </div>
       <script>
       (function(){
@@ -3471,6 +3491,48 @@ ${htmlHeader('/designs')}
       })();
       </script>
       <script>
+      // Basket toggle — mirrors the compare toggle pattern exactly (different key, cap=12)
+      (function(){
+        var addBtn = document.getElementById('cta-add-basket');
+        var viewBtn = document.getElementById('cta-view-basket');
+        var countEl = document.getElementById('cta-basket-count');
+        if (!addBtn || !viewBtn) return;
+        var thisId = parseInt(addBtn.dataset.designId, 10);
+        function getList(){ try { return JSON.parse(localStorage.getItem('wallco-basket') || '[]').filter(function(x){ return typeof x === 'number' && x>0; }); } catch(e){ return []; } }
+        function setList(arr){ try { localStorage.setItem('wallco-basket', JSON.stringify(arr.slice(0,12))); } catch(e){} }
+        function refresh(){
+          var list = getList();
+          if (countEl) countEl.textContent = list.length;
+          if (list.length > 0) {
+            viewBtn.style.display = 'inline-flex';
+            viewBtn.setAttribute('href', '/basket');
+          } else {
+            viewBtn.style.display = 'none';
+          }
+          var lbl = addBtn.querySelector('.cta-add-basket-label');
+          if (list.indexOf(thisId) >= 0) {
+            if (lbl) lbl.textContent = 'In basket';
+            addBtn.dataset.in = '1';
+            addBtn.style.background = 'var(--card-bg, #faf8f3)';
+          } else {
+            if (lbl) lbl.textContent = 'Sample';
+            addBtn.dataset.in = '0';
+            addBtn.style.background = '';
+          }
+        }
+        addBtn.addEventListener('click', function(){
+          var list = getList();
+          var idx = list.indexOf(thisId);
+          if (idx >= 0) { list.splice(idx, 1); }
+          else if (list.length < 12) { list.push(thisId); }
+          else { alert('Sample basket holds 12 designs max. Remove one to add another.'); return; }
+          setList(list);
+          refresh();
+        });
+        refresh();
+      })();
+      </script>
+      <script>
       (function(){
         var btn = document.getElementById('cta-share');
         if (!btn) return;
@@ -4548,6 +4610,199 @@ ${HAMBURGER_JS}
 </html>`);
 });
 
+// ─────────────────────────────────────────────────────────────────────────
+// SAMPLE BASKET — mirrors the /compare pattern (localStorage[wallco-basket],
+// max 12 items, additive to the single-design /samples flow). Multi-design
+// trade sample requests get logged to data/sample-requests.jsonl. No email,
+// no DB writes — just append-only JSONL (YOLO + comms-compliance rules).
+// ─────────────────────────────────────────────────────────────────────────
+app.get('/basket', (req, res) => {
+  const rawIds = String(req.query.ids || '').split(',').map(s => parseInt(s.trim(), 10)).filter(n => Number.isFinite(n) && n > 0).slice(0, 12);
+  const items = rawIds.map(id => DESIGNS.find(d => d.id === id)).filter(Boolean);
+  const esc = (s) => String(s||'').replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));
+
+  res.type('html').send(`${htmlHead({
+    title: 'Sample Basket — wallco.ai',
+    description: items.length ? `Request trade samples for ${items.length} wallco.ai designs.` : 'Build a multi-design sample request basket — up to 12 designs, one trade form.',
+    canonical: 'https://wallco.ai/basket'
+  })}
+<body>
+${htmlHeader('/basket')}
+<main>
+<section class="catalog-section" style="padding:24px 40px;max-width:880px;margin:0 auto">
+  <h1 style="font:300 36px/1.1 var(--serif,Georgia,serif);margin:0 0 6px">Sample basket</h1>
+  <p id="basket-count-line" style="font:13px var(--sans,system-ui);color:var(--ink-soft,#555);margin:0 0 18px">${items.length ? `${items.length} of 12 selected.` : 'Your basket is empty.'}</p>
+
+  ${items.length === 0 ? `
+    <div id="basket-empty" style="padding:40px;border:1px dashed var(--line,#ccc);border-radius:8px;text-align:center;font:14px var(--sans,system-ui);color:var(--ink-faint,#888)">
+      <p>Add designs from any design page using <strong>+ Sample</strong>.</p>
+      <p style="font:12px var(--sans,system-ui);margin-top:14px">Or share a link directly: <code>/basket?ids=72,161</code></p>
+    </div>
+
+    <!-- Discovery rail: random designs to start a basket -->
+    <div style="margin-top:32px">
+      <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:12px">
+        <h2 style="font:300 22px/1.2 var(--serif,Georgia,serif);margin:0">More designs to explore</h2>
+        <button type="button" id="rail-refresh" style="font:11px var(--sans,system-ui);background:transparent;border:1px solid var(--line,#ddd);color:var(--ink-soft,#555);padding:5px 12px;border-radius:999px;cursor:pointer">↻ Shuffle</button>
+      </div>
+      <div id="basket-rail" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:14px"></div>
+      <p style="font:12px var(--sans,system-ui);color:var(--ink-faint,#999);margin-top:12px;text-align:center">Click any design to open it, then hit <strong>+ Sample</strong> on the PDP.</p>
+    </div>
+    <script>
+      // If localStorage has stored ids, auto-redirect to the URL form so the share-link works.
+      try {
+        var stored = JSON.parse(localStorage.getItem('wallco-basket') || '[]');
+        if (Array.isArray(stored) && stored.length > 0) {
+          window.location.href = '/basket?ids=' + stored.slice(0,12).join(',');
+        }
+      } catch(e) {}
+      (function(){
+        var rail = document.getElementById('basket-rail');
+        var btn = document.getElementById('rail-refresh');
+        if (!rail) return;
+        var esc = function(s){ return String(s||'').replace(/[<>&"]/g, function(c){ return {'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]; }); };
+        function load(){
+          rail.innerHTML = '<div style="grid-column:1/-1;padding:40px;text-align:center;color:var(--ink-faint,#999);font:13px var(--sans,system-ui)">Loading…</div>';
+          fetch('/api/designs/random?n=12').then(function(r){ return r.json(); }).then(function(j){
+            if (!j.results || !j.results.length) { rail.innerHTML = '<div style="grid-column:1/-1;padding:40px;text-align:center;color:var(--ink-faint,#999);font:13px var(--sans,system-ui)">No designs available yet.</div>'; return; }
+            rail.innerHTML = j.results.map(function(r){
+              var dot = r.dominant_hex ? '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:'+esc(r.dominant_hex)+';border:1px solid rgba(0,0,0,.12);vertical-align:middle;margin-right:4px"></span>' : '';
+              return '<a href="'+esc(r.url)+'" style="display:block;text-decoration:none;color:inherit"><div style="aspect-ratio:1/1.15;background:#f4f1ea url('+JSON.stringify(r.image_url).slice(1,-1)+') center/cover;border-radius:6px;border:1px solid var(--line,#e6e2d8);margin-bottom:6px"></div><div style="font:13px var(--sans,system-ui);color:var(--ink,#111);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">'+esc(r.title || ('Design #'+r.id))+'</div><div style="font:11px var(--sans,system-ui);color:var(--ink-faint,#999);margin-top:2px">'+dot+esc(r.category||'mixed')+'</div></a>';
+            }).join('');
+          }).catch(function(){ rail.innerHTML = '<div style="grid-column:1/-1;padding:40px;text-align:center;color:var(--ink-faint,#999);font:13px var(--sans,system-ui)">Couldn\\'t load — try again.</div>'; });
+        }
+        load();
+        if (btn) btn.addEventListener('click', load);
+      })();
+    </script>
+  ` : `
+    <!-- Vertically stacked mini-cards (thumb + title + category + Remove) -->
+    <ul id="basket-list" style="list-style:none;margin:0 0 24px;padding:0;display:flex;flex-direction:column;gap:10px">
+      ${items.map(d => `<li data-id="${d.id}" style="display:flex;align-items:center;gap:14px;padding:12px;border:1px solid var(--line,#eee);border-radius:8px;background:var(--card-bg,#faf8f3)">
+        <a href="/design/${d.id}" style="flex:0 0 auto;text-decoration:none">
+          <div style="width:72px;height:72px;background:#f4f1ea url('${esc(d.image_url)}') center/cover;border-radius:6px;border:1px solid var(--line,#ddd)"></div>
+        </a>
+        <div style="flex:1 1 auto;min-width:0">
+          <div style="font:500 16px/1.3 var(--serif,Georgia,serif);color:var(--ink,#111);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
+            <a href="/design/${d.id}" style="color:inherit;text-decoration:none">${esc(d.title || 'Design #' + d.id)}</a>
+          </div>
+          <div style="font:11px/1.4 var(--sans,system-ui);color:var(--ink-faint,#888);margin-top:3px"><span style="display:inline-block;width:9px;height:9px;border-radius:50%;background:${esc(d.dominant_hex||'#888')};border:1px solid rgba(0,0,0,.12);vertical-align:middle;margin-right:4px"></span>${esc(d.category || 'mixed')} · ${esc(d.handle || 'wallco-'+String(d.id).padStart(4,'0'))}</div>
+        </div>
+        <button type="button" data-rmid="${d.id}" class="basket-rm" style="flex:0 0 auto;padding:6px 12px;font:11px var(--sans,system-ui);background:transparent;border:1px solid var(--line,#ddd);color:var(--ink-soft,#555);border-radius:3px;cursor:pointer">Remove</button>
+      </li>`).join('')}
+    </ul>
+
+    <!-- Multi-design sample request form — re-uses /samples fields -->
+    <div class="sample-form-wrap" style="border-top:1px solid var(--line,#eee);padding-top:24px">
+      <h2 style="font:300 24px/1.2 var(--serif,Georgia,serif);margin:0 0 6px">Request samples for these ${items.length} designs</h2>
+      <p style="font:13px var(--sans,system-ui);color:var(--ink-soft,#555);margin:0 0 18px">We'll send physical swatches or high-res digital proofs. No charge, no obligation. Trade welcome.</p>
+      <form id="basket-form" class="sample-form">
+        <label>Your name <input type="text" name="name" required placeholder="Jane Smith"></label>
+        <label>Email <input type="email" name="email" required placeholder="you@example.com"></label>
+        <label>Company / firm (optional) <input type="text" name="company" placeholder="Smith Interiors"></label>
+        <label>Phone (optional) <input type="tel" name="phone" placeholder="+1 (555) 000-0000"></label>
+        <label>Project notes (optional) <textarea name="notes" rows="3" placeholder="Site address, install date, square footage, anything else…"></textarea></label>
+        <button type="submit" class="btn-primary">Send sample request (${items.length} ${items.length === 1 ? 'design' : 'designs'})</button>
+      </form>
+      <div id="basket-result" style="display:none;margin-top:16px;padding:16px;border-radius:4px"></div>
+    </div>
+
+    <script>
+      (function(){
+        var stored = ${JSON.stringify(rawIds)};
+        try { localStorage.setItem('wallco-basket', JSON.stringify(stored)); } catch(e){}
+        document.querySelectorAll('.basket-rm').forEach(function(btn){
+          btn.addEventListener('click', function(){
+            var rid = parseInt(btn.dataset.rmid,10);
+            var next = stored.filter(function(x){ return x !== rid; });
+            try { localStorage.setItem('wallco-basket', JSON.stringify(next)); } catch(e){}
+            window.location.href = next.length ? '/basket?ids=' + next.join(',') : '/basket';
+          });
+        });
+        var form = document.getElementById('basket-form');
+        var result = document.getElementById('basket-result');
+        if (form) form.addEventListener('submit', async function(e){
+          e.preventDefault();
+          var data = Object.fromEntries(new FormData(form));
+          data.ids = stored;
+          try {
+            var r = await fetch('/api/basket/submit', {
+              method: 'POST',
+              headers: {'Content-Type':'application/json'},
+              body: JSON.stringify(data)
+            });
+            var json = await r.json();
+            result.style.display = 'block';
+            if (json.ok) {
+              result.style.background = '#d4edda';
+              result.style.color = '#155724';
+              result.textContent = 'Thank you! Request ' + json.request_id + ' received. We\\'ll be in touch shortly.';
+              form.reset();
+              // Clear the basket after a successful submit
+              try { localStorage.removeItem('wallco-basket'); } catch(e){}
+            } else {
+              result.style.background = '#f8d7da';
+              result.style.color = '#721c24';
+              result.textContent = (json.error || 'Something went wrong') + ' — please email info@wallco.ai directly.';
+            }
+          } catch(err){
+            result.style.display = 'block';
+            result.style.background = '#f8d7da';
+            result.style.color = '#721c24';
+            result.textContent = 'Network error — please try again.';
+          }
+        });
+      })();
+    </script>
+  `}
+</section>
+</main>
+${FOOTER}
+${HAMBURGER_JS}
+</body>
+</html>`);
+});
+
+// ── POST /api/basket/submit — multi-design sample request → JSONL (no email)
+app.post('/api/basket/submit', (req, res) => {
+  const body = req.body || {};
+  const name    = typeof body.name    === 'string' ? body.name.trim()    : '';
+  const email   = typeof body.email   === 'string' ? body.email.trim()   : '';
+  const company = typeof body.company === 'string' ? body.company.trim() : '';
+  const phone   = typeof body.phone   === 'string' ? body.phone.trim()   : '';
+  const notes   = typeof body.notes   === 'string' ? body.notes.trim()   : '';
+  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+    return res.status(400).json({ ok: false, error: 'valid email required' });
+  }
+  const rawIds = Array.isArray(body.ids) ? body.ids : [];
+  const ids = rawIds.map(n => parseInt(n, 10)).filter(n => Number.isFinite(n) && n > 0);
+  const validIds = ids.filter(id => DESIGNS.some(d => d.id === id)).slice(0, 12);
+  if (validIds.length === 0) {
+    return res.status(400).json({ ok: false, error: 'at least one valid design id required' });
+  }
+
+  const ts = Date.now();
+  const request_id = 'sr_' + ts.toString(36) + '_' + Math.random().toString(36).slice(2, 8);
+  const record = {
+    request_id,
+    received_at: new Date(ts).toISOString(),
+    name, email, company, phone, notes,
+    ids: validIds,
+    ip: (req.headers['x-forwarded-for'] || req.ip || '').toString().split(',')[0].trim(),
+    user_agent: (req.headers['user-agent'] || '').toString().slice(0, 300)
+  };
+
+  try {
+    const outPath = path.join(__dirname, 'data', 'sample-requests.jsonl');
+    fs.appendFileSync(outPath, JSON.stringify(record) + '\n', 'utf8');
+  } catch (e) {
+    console.error('basket JSONL append error:', e.message);
+    return res.status(500).json({ ok: false, error: 'failed to record request' });
+  }
+
+  res.json({ ok: true, request_id });
+});
+
 // ── MURALS
 app.get('/murals', (req, res) => {
   const q = String(req.query.q || '').toLowerCase().trim();

← 4295177 wallco.ai · /murals search box parity with /designs — server  ·  back to Wallco Ai  ·  wallco.ai · trade login + saved designs — magic-link auth (n 6db3b6b →