[object Object]

← back to Quadrille Showroom

Build sample-request submit: endpoint + tray form + admin cards (steps 1-3)

38c4172d49b951d82d528cafaaa2d80e3038b226 · 2026-07-02 07:54:28 -0700 · Steve

- POST /api/sample-request: validated (samples 1..50, age 0..120, email, 32kb cap),
  server-stamped created_at, append-only data/sample-requests.jsonl persist.
- Email via George gated behind SAMPLE_REQUEST_EMAIL env (default off) -> queue file only.
- Sample Tray: name/email/phone/notes fields + Request Samples button (disabled when
  empty) + consent line + confirmation UX that keeps input on failure. Age rides along.
- Admin /admin/sample-requests: card list with created date+time chip (standing rule).
Verified: curl (ok+400s+GET), Playwright load (7 elements, button gated, 0 console errors).
Gated/pending: consent wording + email recipient (spec steps 4-5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit 38c4172d49b951d82d528cafaaa2d80e3038b226
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 2 07:54:28 2026 -0700

    Build sample-request submit: endpoint + tray form + admin cards (steps 1-3)
    
    - POST /api/sample-request: validated (samples 1..50, age 0..120, email, 32kb cap),
      server-stamped created_at, append-only data/sample-requests.jsonl persist.
    - Email via George gated behind SAMPLE_REQUEST_EMAIL env (default off) -> queue file only.
    - Sample Tray: name/email/phone/notes fields + Request Samples button (disabled when
      empty) + consent line + confirmation UX that keeps input on failure. Age rides along.
    - Admin /admin/sample-requests: card list with created date+time chip (standing rule).
    Verified: curl (ok+400s+GET), Playwright load (7 elements, button gated, 0 console errors).
    Gated/pending: consent wording + email recipient (spec steps 4-5).
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 public/admin/sample-requests.html |  92 ++++++++++++++++++++++++++++++++++
 public/css/showroom.css           |  13 +++++
 public/js/showroom.js             |  44 +++++++++++++++++
 public/showroom.html              |   9 ++++
 server.js                         | 101 +++++++++++++++++++++++++++++++++++++-
 5 files changed, 258 insertions(+), 1 deletion(-)

diff --git a/public/admin/sample-requests.html b/public/admin/sample-requests.html
new file mode 100644
index 0000000..84d2605
--- /dev/null
+++ b/public/admin/sample-requests.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>Sample Requests — Quadrille House (Admin)</title>
+<style>
+  :root { --gold:#c9a96e; --bg:#0e0e13; --card:#17171f; --line:rgba(201,169,110,0.15); --muted:#8a857a; }
+  * { box-sizing: border-box; }
+  body { margin:0; background:var(--bg); color:#e8e2d4; font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; padding:24px; }
+  header { display:flex; align-items:baseline; gap:14px; flex-wrap:wrap; margin-bottom:18px; }
+  h1 { font-size:20px; font-weight:600; margin:0; letter-spacing:0.02em; }
+  .count { color:var(--muted); font-size:13px; }
+  .controls { margin-left:auto; display:flex; gap:8px; align-items:center; }
+  select, button { background:var(--card); color:#e8e2d4; border:1px solid var(--line); border-radius:6px; padding:6px 10px; font-size:13px; cursor:pointer; }
+  #grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); gap:14px; }
+  .card { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:14px; }
+  .when { display:inline-block; font-size:11px; color:var(--gold); background:rgba(201,169,110,0.08); border:1px solid var(--line); border-radius:999px; padding:2px 9px; margin-bottom:10px; }
+  .id { font-size:10px; color:#5c584f; float:right; }
+  .row { margin:5px 0; font-size:13px; }
+  .row .k { color:var(--muted); margin-right:6px; }
+  .samples { display:flex; flex-wrap:wrap; gap:6px; margin:8px 0; }
+  .chip { display:flex; align-items:center; gap:6px; background:rgba(0,0,0,0.3); border:1px solid var(--line); border-radius:6px; padding:3px 8px; font-size:12px; }
+  .dot { width:12px; height:12px; border-radius:3px; border:1px solid rgba(255,255,255,0.15); flex-shrink:0; }
+  .notes { margin-top:8px; padding-top:8px; border-top:1px solid var(--line); color:#c9c3b6; font-size:12px; white-space:pre-wrap; }
+  .empty { color:var(--muted); padding:40px; text-align:center; }
+</style>
+</head>
+<body>
+<header>
+  <h1>Sample Requests</h1>
+  <span class="count" id="count">—</span>
+  <div class="controls">
+    <select id="sort">
+      <option value="newest">Newest first</option>
+      <option value="oldest">Oldest first</option>
+    </select>
+    <button id="refresh">Refresh</button>
+  </div>
+</header>
+<div id="grid"></div>
+
+<script>
+const COLORS = {'Navy':'#1a2744','Sage':'#6b7f5e','Cream':'#f0ead6','Gold':'#c9a96e','Silver':'#b8b8c0','Blush':'#d4a0a0','Charcoal':'#3a3a42','Ivory':'#f5f0e8','Slate':'#5a6068','Teal':'#2a6b6b','Coral':'#cd6858','Burgundy':'#6b2040'};
+let ALL = [];
+
+// Standing admin-card rule: show created DATE + TIME in the admin's local timezone.
+function fmtDate(iso) {
+  const d = new Date(iso);
+  if (isNaN(d)) return iso || '';
+  return d.toLocaleString(undefined, { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
+}
+const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
+
+function render() {
+  const grid = document.getElementById('grid');
+  const rows = ALL.slice();
+  if (document.getElementById('sort').value === 'oldest') rows.reverse();
+  document.getElementById('count').textContent = rows.length + (rows.length === 1 ? ' request' : ' requests');
+  if (!rows.length) { grid.innerHTML = '<div class="empty">No sample requests yet.</div>'; return; }
+  grid.innerHTML = rows.map(r => {
+    const c = r.contact || {};
+    const samples = (r.samples || []).map(s =>
+      `<span class="chip"><span class="dot" style="background:${COLORS[s.color] || '#d8d4cc'}"></span>${esc(s.pattern_name || s.sku)}${s.color ? ' · ' + esc(s.color) : ''}</span>`).join('');
+    const line = (k, v) => v ? `<div class="row"><span class="k">${k}</span>${esc(v)}</div>` : '';
+    return `<div class="card">
+      <span class="id">${esc(r.id || '')}</span>
+      <span class="when" title="${esc(r.created_at || '')}">🕓 ${esc(fmtDate(r.created_at))}</span>
+      <div class="samples">${samples || '<span class="k">no samples</span>'}</div>
+      ${line('Age', r.age != null ? r.age : '')}
+      ${line('Name', c.name)}
+      ${line('Email', c.email)}
+      ${line('Phone', c.phone)}
+      ${r.notes ? `<div class="notes">${esc(r.notes)}</div>` : ''}
+    </div>`;
+  }).join('');
+}
+
+async function load() {
+  try {
+    const res = await fetch('/api/sample-requests');
+    const data = await res.json();
+    ALL = data.requests || [];   // server returns newest-first
+  } catch (e) { ALL = []; }
+  render();
+}
+document.getElementById('sort').addEventListener('change', render);
+document.getElementById('refresh').addEventListener('click', load);
+load();
+</script>
+</body>
+</html>
diff --git a/public/css/showroom.css b/public/css/showroom.css
index da73e94..fdaba5a 100644
--- a/public/css/showroom.css
+++ b/public/css/showroom.css
@@ -139,6 +139,19 @@ body { overflow: hidden; background: #0a0a0f; font-family: 'Segoe UI', system-ui
 .tray-age-opt { color: #666; }
 #tray-age-input { width: 56px; background: rgba(0,0,0,0.35); border: 1px solid rgba(201,169,110,0.3); border-radius: 6px; color: #e8e2d4; font-size: 12px; padding: 4px 7px; text-align: center; }
 #tray-age-input:focus { outline: none; border-color: #c9a96e; }
+/* Sample-request contact form — rides along with the tray submit. */
+#tray-contact { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
+.tray-field { width: 100%; box-sizing: border-box; background: rgba(0,0,0,0.35); border: 1px solid rgba(201,169,110,0.3); border-radius: 6px; color: #e8e2d4; font-size: 12px; padding: 6px 8px; font-family: inherit; }
+.tray-field::placeholder { color: #7a7568; }
+.tray-field:focus { outline: none; border-color: #c9a96e; }
+textarea.tray-field { resize: vertical; min-height: 34px; }
+#tray-submit { width: 100%; margin-top: 8px; background: #c9a96e; color: #0a0a0f; border: none; border-radius: 6px; padding: 8px; font-size: 12px; font-weight: 700; cursor: pointer; letter-spacing: 0.02em; }
+#tray-submit:disabled { opacity: 0.4; cursor: not-allowed; }
+#tray-submit:not(:disabled):hover { background: #d8bd85; }
+#tray-consent { margin-top: 6px; font-size: 10px; color: #666; line-height: 1.35; }
+#tray-status { margin-top: 6px; font-size: 11px; min-height: 14px; }
+#tray-status.ok { color: #6b9f6e; }
+#tray-status.err { color: #cd6858; }
 
 /* Bottom Bar */
 #bottom-bar {
diff --git a/public/js/showroom.js b/public/js/showroom.js
index 1adb111..801fae6 100644
--- a/public/js/showroom.js
+++ b/public/js/showroom.js
@@ -3919,6 +3919,48 @@ function initHUD() {
     });
   }
 
+  // Sample-request SUBMIT — POST the tray + age + contact to the server. On success the tray
+  // clears; on failure everything is kept so the visitor never loses their input.
+  const submitBtn = document.getElementById('tray-submit');
+  if (submitBtn) {
+    const statusEl = document.getElementById('tray-status');
+    const setStatus = (msg, cls) => { if (statusEl) { statusEl.textContent = msg; statusEl.className = cls || ''; } };
+    submitBtn.addEventListener('click', async () => {
+      if (!sampleTray.length || submitBtn.disabled) return;
+      const val = id => (document.getElementById(id)?.value || '').trim();
+      const payload = {
+        samples: sampleTray.map(p => ({ sku: p.sku, pattern_name: p.pattern_name, color: p.color })),
+        age: visitorAge,
+        contact: { name: val('tray-name'), email: val('tray-email'), phone: val('tray-phone') },
+        notes: val('tray-notes'),
+      };
+      submitBtn.disabled = true; setStatus('Sending…', '');
+      try {
+        const res = await fetch('/api/sample-request', {
+          method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
+        });
+        const data = await res.json().catch(() => ({}));
+        if (!res.ok || !data.ok) throw new Error(data.error || ('HTTP ' + res.status));
+        // Success — clear tray + form + persisted age, then confirm.
+        sampleTray.length = 0;
+        ['tray-name', 'tray-email', 'tray-phone', 'tray-notes', 'tray-age-input'].forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; });
+        visitorAge = null; localStorage.removeItem('qh_visitor_age');
+        updateSampleTray();   // clears chips + disables button + stows the empty tray
+        // Un-stow so the confirmation is actually visible, then auto-tidy if still empty.
+        const trayEl = document.getElementById('sample-tray');
+        if (trayEl) { trayEl.classList.remove('qh-stowed'); trayEl.classList.add('visible'); }
+        setStatus('Request sent — thank you!', 'ok');
+        setTimeout(() => {
+          if (!sampleTray.length && trayEl) { trayEl.classList.remove('visible'); trayEl.classList.add('qh-stowed'); setStatus('', ''); }
+        }, 4500);
+      } catch (e) {
+        submitBtn.disabled = false;   // keep everything; let them retry
+        setStatus('Could not send — please try again.', 'err');
+        console.warn('[sample-request] submit failed:', e.message);
+      }
+    });
+  }
+
   // REVEAL slider — how much of each closed board's pattern shows in the packed arc.
   // Live: re-rakes every board's closed yaw via QHRack.relayout (no full rebuild), so
   // the open hero stays open while the slivers widen/narrow underneath. Persisted.
@@ -4136,6 +4178,8 @@ window._qhStartCenterWalk = _qhStandInDoorway;   // legacy alias (auto-walk reti
 function updateSampleTray() {
   const tray = document.getElementById('sample-tray'), items = document.getElementById('tray-items');
   document.getElementById('tray-count').textContent = sampleTray.length;
+  const submitBtn = document.getElementById('tray-submit');
+  if (submitBtn) submitBtn.disabled = sampleTray.length === 0;
   if (sampleTray.length > 0) {
     // The dock stows the tray on boot with .qh-stowed (display:none!important); clear it so
     // the tray can actually surface when a sample is added (its .visible display:block can't
diff --git a/public/showroom.html b/public/showroom.html
index 46054db..eefb87e 100644
--- a/public/showroom.html
+++ b/public/showroom.html
@@ -303,6 +303,15 @@
         <label for="tray-age-input">Your age <span class="tray-age-opt">(optional)</span></label>
         <input id="tray-age-input" type="number" min="0" max="120" step="1" inputmode="numeric" placeholder="—" autocomplete="off">
       </div>
+      <div id="tray-contact">
+        <input id="tray-name" class="tray-field" type="text" maxlength="120" placeholder="Name (optional)" autocomplete="name">
+        <input id="tray-email" class="tray-field" type="email" maxlength="160" placeholder="Email (optional)" autocomplete="email">
+        <input id="tray-phone" class="tray-field" type="tel" maxlength="40" placeholder="Phone (optional)" autocomplete="tel">
+        <textarea id="tray-notes" class="tray-field" maxlength="2000" rows="2" placeholder="Notes (optional)"></textarea>
+      </div>
+      <button id="tray-submit" type="button" disabled>Request Samples</button>
+      <div id="tray-consent">We'll use your details only to send the samples you requested.</div>
+      <div id="tray-status" role="status" aria-live="polite"></div>
     </div>
 
     <canvas id="minimap" width="160" height="128"></canvas>
diff --git a/server.js b/server.js
index 2df030f..df5f8e0 100644
--- a/server.js
+++ b/server.js
@@ -42,7 +42,7 @@ loadProducts();
 // MIDDLEWARE
 // ============================================================
 app.use(compression());
-app.use(express.json());
+app.use(express.json({ limit: '32kb' }));  // cap body — sample-request is the only POST
 
 // Basic auth (gates everything — standalone, like dw-showroom).
 // Localhost is exempt: this is a local-only tool (DTD verdict C), so direct
@@ -116,6 +116,105 @@ app.get('/api/showroom/vendors', (req, res) => {
   res.json({ vendors, total: META.count });
 });
 
+// ============================================================
+// SAMPLE REQUEST — the tray submit flow. Age rides along here.
+// Local-only kiosk tool (see auth note above); v1 captures PII, so treat records as
+// PII-bearing. Storage = append-only JSONL (source of truth). Email via George is gated
+// behind SAMPLE_REQUEST_EMAIL (default off) so this ships capturing-to-file immediately;
+// see SAMPLE-REQUEST-SPEC.md steps 4–5 for the gated pieces (consent copy + recipient).
+// ============================================================
+const SAMPLE_REQ_FILE = path.join(DATA_DIR, 'sample-requests.jsonl');
+const EMAIL_QUEUE_FILE = path.join(DATA_DIR, 'sample-request-emails.jsonl');
+
+const clampStr = (v, max) =>
+  typeof v === 'string' ? v.replace(/[\x00-\x1F\x7F]/g, '').trim().slice(0, max) : '';
+// Like clampStr but keeps newlines (\n) + tabs (\t) — for the multi-line notes field.
+const clampMulti = (v, max) =>
+  typeof v === 'string' ? v.replace(/[\x00-\x08\x0B-\x1F\x7F]/g, '').trim().slice(0, max) : '';
+const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+// Gated email hook — writes a pending-send record to a queue file ONLY when the env flag is
+// on. It does NOT send: a George picker turns queued rows into real mail once Steve enables
+// it + sets the recipient. Best-effort; never fails the request.
+function maybeQueueEmail(record) {
+  if (String(process.env.SAMPLE_REQUEST_EMAIL || 'off').toLowerCase() !== 'on') return;
+  try {
+    fs.appendFileSync(EMAIL_QUEUE_FILE, JSON.stringify({
+      queued_at: new Date().toISOString(), sent: false,
+      to: process.env.SAMPLE_REQUEST_EMAIL_TO || null, request: record,
+    }) + '\n');
+  } catch (e) { console.error('[sample-request] email queue failed:', e.message); }
+}
+
+app.post('/api/sample-request', (req, res) => {
+  const b = req.body || {};
+
+  // samples — required, 1..50, each with a string sku
+  if (!Array.isArray(b.samples) || b.samples.length < 1 || b.samples.length > 50) {
+    return res.status(400).json({ ok: false, error: 'samples must be a 1..50 array' });
+  }
+  const samples = [];
+  for (const s of b.samples) {
+    if (!s || typeof s.sku !== 'string' || !s.sku.trim()) {
+      return res.status(400).json({ ok: false, error: 'each sample needs a sku' });
+    }
+    samples.push({
+      sku: clampStr(s.sku, 64),
+      pattern_name: clampStr(s.pattern_name, 200),
+      color: clampStr(s.color, 80),
+    });
+  }
+
+  // age — null OR integer 0..120 (mirrors the client clamp)
+  let age = null;
+  if (b.age !== null && b.age !== undefined && b.age !== '') {
+    const n = parseInt(b.age, 10);
+    if (!Number.isFinite(n) || n < 0 || n > 120) {
+      return res.status(400).json({ ok: false, error: 'age must be 0..120 or null' });
+    }
+    age = n;
+  }
+
+  // contact — all optional; email format-checked if present
+  const contact = {
+    name: clampStr(b.contact && b.contact.name, 120),
+    email: clampStr(b.contact && b.contact.email, 160),
+    phone: clampStr(b.contact && b.contact.phone, 40),
+  };
+  if (contact.email && !EMAIL_RE.test(contact.email)) {
+    return res.status(400).json({ ok: false, error: 'invalid email' });
+  }
+  const notes = clampMulti(b.notes, 2000);
+
+  const created_at = new Date().toISOString();
+  const crypto = require('crypto');
+  const id = 'sr_' + crypto.createHash('sha1')
+    .update(created_at + JSON.stringify(samples)).digest('hex').slice(0, 12);
+  const record = { id, created_at, ip: req.ip || null, age, samples, contact, notes };
+
+  try {
+    fs.appendFileSync(SAMPLE_REQ_FILE, JSON.stringify(record) + '\n');
+  } catch (e) {
+    console.error('[sample-request] persist failed:', e.message);
+    return res.status(500).json({ ok: false, error: 'persist failed' });
+  }
+  maybeQueueEmail(record);  // best-effort, gated
+  res.json({ ok: true, id, created_at });
+});
+
+// Admin read-back — newest first. Gated by the same auth as everything else.
+app.get('/api/sample-requests', (req, res) => {
+  let rows = [];
+  try {
+    const raw = fs.readFileSync(SAMPLE_REQ_FILE, 'utf8');
+    rows = raw.split('\n').filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
+  } catch (e) { /* no file yet → empty list */ }
+  rows.reverse();
+  res.json({ requests: rows, total: rows.length });
+});
+app.get('/admin/sample-requests', (req, res) =>
+  res.sendFile(path.join(__dirname, 'public', 'admin', 'sample-requests.html')));
+
 // Hardened image proxy — whitelist hosts (defends SSRF + handles no-CORS S3/quadrillefabrics).
 const ALLOWED_HOSTS = new Set([
   'cdn.shopify.com',

← cc9d428 Scope the sample-request submit endpoint (age rides along)  ·  back to Quadrille Showroom  ·  Wire the George email picker for sample requests (sending ga fb869a9 →