← back to Zuber Internal
public/index.html
343 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Zuber — Designer Wallcoverings (internal)</title>
<style>
:root { --cols: 5; --fs: 13px; --teal: #0f766e; --ink: #1a1a1a; --mut:#6a6a72; --line: #e2e2e0; --bg: #fafaf8; --card:#f6f6f4; }
* { box-sizing: border-box; }
body { margin: 0; font: var(--fs)/1.45 -apple-system, "Helvetica Neue", Arial, sans-serif; color: var(--ink); background: var(--bg); }
header { position: sticky; top: 0; z-index: 20; background: #fff; border-bottom: 1px solid var(--line); padding: 10px 16px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
header .brand { font-weight: 700; letter-spacing: .04em; text-transform: uppercase; font-size: 13px; }
header .sub { color: #888; font-size: 11px; }
header .spacer { flex: 1; }
header label { font-size: 11px; color: #666; display: inline-flex; align-items: center; gap: 6px; }
select, input[type=search] { font: inherit; padding: 5px 8px; border: 1px solid var(--line); border-radius: 4px; background: #fff; }
input[type=range] { vertical-align: middle; }
.layout { display: grid; grid-template-columns: 262px 1fr; gap: 0; }
/* ── left rail (all.dw parity) ── */
aside { border-right: 1px solid var(--line); background: #fff; height: calc(100vh - 52px); overflow-y: auto; position: sticky; top: 52px; padding: 10px 10px 60px; }
aside .search { width: 100%; margin-bottom: 10px; }
/* active-filter chip row */
.active { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.afilter { background: #e6f4f2; border: 1px solid var(--teal); color: var(--ink); border-radius: 20px; padding: 3px 9px; font-size: 11px; cursor: pointer; }
.afilter b { color: var(--teal); font-weight: 600; }
.clearall { background: none; border: 1px solid var(--line); color: var(--mut); border-radius: 20px; padding: 3px 11px; font-size: 11px; cursor: pointer; }
/* collapsible facet sections */
details.sec { border: 1px solid var(--line); border-radius: 7px; margin-bottom: 7px; background: var(--card); overflow: hidden; }
details.sec > summary { list-style: none; cursor: pointer; user-select: none; padding: 8px 10px; font-size: 10.5px; letter-spacing: .09em; text-transform: uppercase; color: var(--mut); display: flex; align-items: center; gap: 7px; font-weight: 600; }
details.sec > summary::-webkit-details-marker { display: none; }
details.sec > summary::before { content: '▸'; color: var(--teal); font-size: 10px; }
details.sec[open] > summary::before { content: '▾'; }
details.sec > summary .n { margin-left: auto; background: var(--teal); color: #fff; border-radius: 20px; padding: 0 7px; font-size: 10px; font-weight: 700; }
.fbody { padding: 2px 8px 9px; max-height: 300px; overflow-y: auto; }
.fitem { display: flex; align-items: center; gap: 7px; padding: 2.5px 2px; cursor: pointer; font-size: 12.5px; color: var(--ink); border-radius: 5px; }
.fitem:hover { background: #eaeae7; }
.fitem .box { width: 13px; height: 13px; flex: 0 0 13px; border: 1px solid var(--mut); border-radius: 3px; display: inline-flex; align-items: center; justify-content: center; font-size: 10px; }
.fitem.on .box { background: var(--teal); border-color: var(--teal); color: #fff; font-weight: 700; }
.fitem.on { color: var(--teal); }
.fitem .swatch { width: 12px; height: 12px; flex: 0 0 12px; border-radius: 3px; border: 1px solid rgba(0,0,0,.18); }
.fitem .lbl { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fitem .ct { color: var(--mut); font-size: 10.5px; }
.fitem.zero { opacity: .35; }
.more { color: var(--teal); font-size: 11.5px; cursor: pointer; padding: 3px 2px; display: inline-block; }
.ffind { width: 100%; padding: 4px 8px !important; font-size: 11.5px !important; border: 1px solid var(--line); border-radius: 6px !important; margin: 2px 0 5px; }
main { padding: 14px 16px 60px; }
.toolbar { display: flex; align-items: center; gap: 14px; margin-bottom: 12px; flex-wrap: wrap; }
.count { color: #888; font-size: 12px; }
.grid { display: grid; grid-template-columns: repeat(var(--cols), 1fr); gap: 10px; }
.card { border: 1px solid var(--line); border-radius: 6px; background: #fff; overflow: hidden; display: flex; flex-direction: column; }
.card .img { aspect-ratio: 4/3; background: #eee center/cover no-repeat; }
.card .body { padding: 8px 9px; display: flex; flex-direction: column; gap: 3px; }
.card .pat { font-weight: 600; font-size: var(--fs); line-height: 1.2; }
.card .fr { color: #999; font-size: calc(var(--fs) - 2px); font-style: italic; }
.card .meta { color: #777; font-size: calc(var(--fs) - 2px); }
.card .when { color: #999; font-size: 10px; margin-top: 2px; }
.card a { color: var(--teal); text-decoration: none; font-size: 11px; }
/* purchasing actions on each card: Memo / Stock / Price */
.card .acts { display: flex; gap: 4px; padding: 0 9px 8px; }
.card .acts .act { flex: 1; font: inherit; font-size: 10px; color: var(--mut); background: #fff; border: 1px solid var(--line); border-radius: 5px; padding: 4px 2px; cursor: pointer; }
.card .acts .act:hover { border-color: var(--teal); color: var(--ink); }
.card .acts .act:disabled { opacity: .5; cursor: default; }
body.imgonly .card .acts { display: none; }
body.imgonly .card .body { display: none; }
body.imgonly .grid { gap: 3px; }
#toast { position: fixed; left: 50%; bottom: 18px; transform: translateX(-50%) translateY(20px); max-width: min(560px,92vw);
background: #fff; color: var(--ink); border: 1px solid var(--line); border-radius: 8px; padding: 10px 16px; font-size: 13px;
box-shadow: 0 8px 30px rgba(0,0,0,.18); opacity: 0; pointer-events: none; transition: .25s; z-index: 50; }
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
#toast.ok { border-color: #2a6a4a; } #toast.err { border-color: #a33; }
.fields { display: inline-flex; gap: 10px; font-size: 11px; color: #666; }
.fields label { display: inline-flex; gap: 3px; }
</style>
</head>
<body>
<header>
<div>
<div class="brand">Zuber</div>
<div class="sub">Designer Wallcoverings — internal · quote-only · made-to-order</div>
</div>
<div class="spacer"></div>
<label>Sort
<select id="sort">
<option value="newest">Newest</option>
<option value="color">Color</option>
<option value="pattern">Pattern A→Z</option>
<option value="collection">Collection</option>
<option value="sku">SKU A→Z</option>
</select>
</label>
<label>Density <input type="range" id="cols" min="2" max="9" value="5" /></label>
<label>Text <input type="range" id="fs" min="10" max="18" value="13" /></label>
<label><input type="checkbox" id="imgonly" /> Image-only</label>
<span class="fields">
<label><input type="checkbox" class="ff" data-f="pat" checked> Name</label>
<label><input type="checkbox" class="ff" data-f="fr" checked> FR</label>
<label><input type="checkbox" class="ff" data-f="meta" checked> Meta</label>
<label><input type="checkbox" class="ff" data-f="when" checked> Date</label>
</span>
</header>
<div class="layout">
<aside>
<input type="search" id="q" class="search" placeholder="Search pattern / SKU / collection…" />
<div class="active" id="activeRow"></div>
<div id="facets"></div>
</aside>
<main>
<div class="toolbar"><span class="count" id="count"></span></div>
<div class="grid" id="grid"></div>
</main>
</div>
<script>
const $ = (s, r=document) => r.querySelector(s);
const API = location.origin;
// facet dimensions: [stateKey, apiKey, label] (single-vendor → NO vendor facet)
const FACET_DEFS = [
['types', 'product_type', 'Type'],
['collection', 'collection', 'Book / Series'],
['colors', 'color', 'Color'],
['styles', 'style', 'Style'],
['materials', 'material', 'Material'],
['prices', 'price', 'Price'],
['tags', 'tag', 'Tag'],
['images', 'image', 'Image'],
];
// active-chip labels + which apiKey emits family_order/family_swatch
const CHIP_LABEL = { types: 'Type', collection: 'Book', colors: 'Color', styles: 'Style', materials: 'Material', prices: 'Price', tags: 'Tag', images: 'Image' };
const state = { q: '', sort: 'newest' };
for (const [k] of FACET_DEFS) state[k] = new Set();
function ls(k, d) { try { return JSON.parse(localStorage.getItem('zi_'+k)) ?? d; } catch { return d; } }
function save(k, v) { localStorage.setItem('zi_'+k, JSON.stringify(v)); }
// restore facet selections from localStorage
for (const [k] of FACET_DEFS) { const arr = ls('sel_'+k, []); if (Array.isArray(arr)) state[k] = new Set(arr); }
function persistFacets() { for (const [k] of FACET_DEFS) save('sel_'+k, [...state[k]]); }
// restore header prefs
$('#cols').value = ls('cols', 5); document.documentElement.style.setProperty('--cols', $('#cols').value);
$('#fs').value = ls('fs', 13); document.documentElement.style.setProperty('--fs', $('#fs').value + 'px');
$('#imgonly').checked = ls('imgonly', false); document.body.classList.toggle('imgonly', $('#imgonly').checked);
$('#sort').value = ls('sort', 'newest'); state.sort = $('#sort').value;
$('#q').value = ls('q', ''); state.q = $('#q').value;
['pat','fr','meta','when'].forEach(f => { const on = ls('ff_'+f, true); document.querySelector('.ff[data-f="'+f+'"]').checked = on; });
$('#cols').oninput = e => { document.documentElement.style.setProperty('--cols', e.target.value); save('cols', +e.target.value); };
$('#fs').oninput = e => { document.documentElement.style.setProperty('--fs', e.target.value + 'px'); save('fs', +e.target.value); };
$('#imgonly').onchange = e => { document.body.classList.toggle('imgonly', e.target.checked); save('imgonly', e.target.checked); };
$('#sort').onchange = e => { state.sort = e.target.value; save('sort', e.target.value); load(); };
$('#q').oninput = e => { state.q = e.target.value; save('q', e.target.value); clearTimeout(window._t); window._t = setTimeout(applyFilterChange, 220); };
document.querySelectorAll('.ff').forEach(cb => cb.onchange = () => { applyField(cb.dataset.f, cb.checked); save('ff_'+cb.dataset.f, cb.checked); });
function applyField(f, on) { document.querySelectorAll('.card .'+f).forEach(el => el.style.display = on ? '' : 'none'); }
function fmtDate(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d)) return '';
return d.toLocaleString(undefined, { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
}
// build the query params shared by /api/facets + /api/products
function facetParams(extra = {}) {
const p = new URLSearchParams();
if (state.q) p.set('q', state.q);
p.set('sort', state.sort);
for (const [k, apiKey] of FACET_DEFS.map(([k,a]) => [k,a])) { const set = state[k]; if (set && set.size) p.set(apiKey, [...set].join('|')); }
for (const [k, v] of Object.entries(extra)) p.set(k, v);
return p;
}
// note: FACET_DEFS maps state key → api key; facetParams uses the api key as the query param
function toggleSet(set, val) { set.has(val) ? set.delete(val) : set.add(val); applyFilterChange(); }
function applyFilterChange() { persistFacets(); refreshFacets(); load(); }
// per-section UI state: type-ahead text + expand
const rowUI = {};
for (const [k] of FACET_DEFS) rowUI[k] = { find: '', expand: false, typeahead: false };
rowUI.styles.typeahead = true; rowUI.tags.typeahead = true; rowUI.collection.typeahead = false;
let LAST_FACETS = {};
// Amazon-style facet list: checkbox rows + counts, optional swatch, type-ahead, cap/expand.
function facetList(host, stateKey, counts, opts = {}) {
host.innerHTML = '';
const ui = rowUI[stateKey];
const set = state[stateKey];
if (ui.typeahead) {
const find = document.createElement('input');
find.type = 'text'; find.className = 'ffind'; find.placeholder = 'find…'; find.value = ui.find;
find.oninput = () => { ui.find = find.value; renderFacets(LAST_FACETS); const f2 = host.querySelector('.ffind'); if (f2) { f2.focus(); f2.setSelectionRange(f2.value.length, f2.value.length); } };
host.appendChild(find);
}
let entries = Object.entries(counts || {});
entries = opts.order
? entries.sort((a, b) => opts.order.indexOf(a[0]) - opts.order.indexOf(b[0]))
: entries.sort((a, b) => b[1] - a[1]);
if (ui.find) entries = entries.filter(([v]) => v.toLowerCase().includes(ui.find.toLowerCase()));
const CAP = opts.cap || 8;
let shown = entries, hidden = 0;
if (!ui.expand && entries.length > CAP) { shown = entries.slice(0, CAP); hidden = entries.length - CAP; }
const shownVals = new Set(shown.map(([v]) => v));
for (const v of set) if (!shownVals.has(v)) { const e = entries.find(([x]) => x === v); shown.push(e || [v, 0]); }
if (!shown.length) { const s = document.createElement('div'); s.style.cssText = 'font-size:10px;color:#6a6a72;padding:2px'; s.textContent = 'none'; host.appendChild(s); return; }
shown.forEach(([val, ct]) => {
const el = document.createElement('div');
el.className = 'fitem' + (set.has(val) ? ' on' : '') + (ct ? '' : ' zero');
let swatch = '';
if (opts.swatch && opts.swatch[val]) { const bg = opts.swatch[val]; swatch = `<span class="swatch" style="background:${bg}"></span>`; }
el.innerHTML = `<span class="box">${set.has(val) ? '✓' : ''}</span>${swatch}<span class="lbl" title="${val.replace(/"/g, '"')}">${val}</span><span class="ct">${ct.toLocaleString()}</span>`;
el.onclick = () => toggleSet(set, val);
host.appendChild(el);
});
if (hidden > 0) {
const el = document.createElement('span'); el.className = 'more'; el.textContent = `See ${hidden.toLocaleString()} more ▾`;
el.onclick = () => { ui.expand = true; renderFacets(LAST_FACETS); };
host.appendChild(el);
} else if (ui.expand && entries.length > CAP) {
const el = document.createElement('span'); el.className = 'more'; el.textContent = 'See less ▴';
el.onclick = () => { ui.expand = false; renderFacets(LAST_FACETS); };
host.appendChild(el);
}
}
// Build/refresh the section shells once, fill bodies on every render.
function ensureSections() {
if ($('#facets').dataset.built) return;
const host = $('#facets');
for (const [k, apiKey, label] of FACET_DEFS) {
const det = document.createElement('details'); det.className = 'sec'; det.dataset.k = k;
if (ls('open_'+k, k === 'collection' || k === 'colors')) det.open = true;
det.innerHTML = `<summary>${label}<span class="n" id="n_${k}"></span></summary><div class="fbody" id="body_${k}"></div>`;
det.addEventListener('toggle', () => save('open_'+k, det.open));
host.appendChild(det);
}
$('#facets').dataset.built = '1';
}
function renderFacets(f) {
LAST_FACETS = f;
ensureSections();
const capMap = { types: 6, collection: 12, colors: 17, styles: 12, materials: 10, prices: 4, tags: 14, images: 3 };
for (const [k, apiKey] of FACET_DEFS.map(([k,a]) => [k,a])) {
const body = $('#body_'+k); if (!body) continue;
const opts = { cap: capMap[k] || 8 };
if (k === 'colors') { opts.order = f.family_order; opts.swatch = f.family_swatch; }
facetList(body, k, f[apiKey] || {}, opts);
}
renderActive();
}
function renderActive() {
const row = $('#activeRow'); row.innerHTML = '';
let n = 0;
for (const [k] of FACET_DEFS) {
const set = state[k];
const badge = $('#n_'+k);
if (badge) { badge.textContent = set.size || ''; badge.style.display = set.size ? '' : 'none'; }
set.forEach((v) => {
n++;
const el = document.createElement('span'); el.className = 'afilter';
el.innerHTML = `<b>${CHIP_LABEL[k]}:</b> ${v} ✕`;
el.onclick = () => { set.delete(v); applyFilterChange(); };
row.appendChild(el);
});
}
if (n) {
const c = document.createElement('button'); c.className = 'clearall'; c.textContent = 'Clear all';
c.onclick = () => { for (const [k] of FACET_DEFS) state[k].clear(); applyFilterChange(); };
row.appendChild(c);
}
}
async function refreshFacets() {
try {
const f = await (await fetch(API + '/api/facets?' + facetParams())).json();
renderFacets(f);
} catch (e) { /* facets degrade silently — grid still works */ }
}
async function load() {
const data = await (await fetch(API + '/api/products?' + facetParams())).json();
$('#count').textContent = `${data.total} of ${data.all} products`;
const grid = $('#grid'); grid.innerHTML = '';
for (const r of data.products) {
const c = document.createElement('div'); c.className = 'card';
// data-attrs the delegated purchasing handler reads (dataset assignment is injection-safe)
c.dataset.sku = r.dw_sku || '';
c.dataset.mfr = r.mfr_sku || '';
c.dataset.title = r.pattern_name || r.mfr_sku || '';
const img = document.createElement('div'); img.className = 'img';
if (r.image) img.style.backgroundImage = `url("${r.image}")`;
c.appendChild(img);
const body = document.createElement('div'); body.className = 'body';
const meta = [r.collection, r.width].filter(Boolean).join(' · ');
const frLine = (r.pattern_name_fr && r.pattern_name_fr !== r.pattern_name) ? `<div class="fr">${r.pattern_name_fr}</div>` : '';
body.innerHTML = `
<div class="pat">${r.pattern_name || r.mfr_sku}</div>
${frLine}
<div class="meta">${r.mfr_sku}${meta ? ' — ' + meta : ''}</div>
<div class="when" title="${r.created_at || ''}">🕓 ${fmtDate(r.created_at)}</div>
${r.product_url ? `<a href="${r.product_url}" target="_blank" rel="noopener noreferrer">Source ↗</a>` : ''}`;
c.appendChild(body);
const acts = document.createElement('div'); acts.className = 'acts';
acts.innerHTML = `
<button class="act" data-act="memo" title="Log a memo-sample request">Memo</button>
<button class="act" data-act="stock" title="Email the vendor a stock check">Stock</button>
<button class="act" data-act="price" title="Email the vendor for current price">Price</button>`;
c.appendChild(acts);
grid.appendChild(c);
}
['pat','fr','meta','when'].forEach(f => applyField(f, document.querySelector('.ff[data-f="'+f+'"]').checked));
}
refreshFacets().then(load);
setInterval(load, 60000);
// ── Purchasing actions on each card: Memo / Stock / Price → /api/request ──
$('#grid').addEventListener('click', async (e) => {
const btn = e.target.closest('.act'); if (!btn) return;
const card = btn.closest('.card'); const type = btn.dataset.act;
const payload = { type, dw_sku: card.dataset.sku, mfr_sku: card.dataset.mfr, title: card.dataset.title };
const old = btn.textContent; btn.disabled = true; btn.textContent = '…';
try {
const r = await fetch(location.origin + '/api/request', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }).then(r => r.json());
toast(r.ok ? (r.message || ('Logged ' + (r.req_no || ''))) : (r.error || 'request failed'), !!r.ok);
} catch (err) { toast('request failed: ' + err.message, false); }
btn.disabled = false; btn.textContent = old;
});
let toastT = null;
function toast(msg, ok) {
let t = document.getElementById('toast');
if (!t) { t = document.createElement('div'); t.id = 'toast'; document.body.appendChild(t); }
t.textContent = msg; t.className = 'show ' + (ok ? 'ok' : 'err');
clearTimeout(toastT); toastT = setTimeout(() => t.className = '', 6000);
}
</script>
</body>
</html>