← 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
A public/admin/sample-requests.htmlM public/css/showroom.cssM public/js/showroom.jsM public/showroom.htmlM server.js
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 => ({'&':'&','<':'<','>':'>','"':'"'}[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 →