← 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, '&quot;')}">${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>