← 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 => ({'<':'<','>':'>','&':'&','"':'"'}[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 {'<':'<','>':'>','&':'&','"':'"'}[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 →