← back to Zuber Internal
public/index.html
476 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; }
/* ── drill atom: every data point is an href to its filtered (deeper) view ── */
.drill { color: inherit; text-decoration: none; cursor: pointer; border-bottom: 1px dotted transparent; }
.drill:hover { color: var(--teal); border-bottom-color: var(--teal); }
.fitem .lbl .drill { display: block; overflow: hidden; text-overflow: ellipsis; }
/* 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; }
/* ── Grid / List toggle ── */
.vtoggle { display:flex; border:1px solid var(--line); border-radius:4px; overflow:hidden; }
.vtoggle button { background:#fff; color:#888; border:none; padding:5px 11px; font:11px/1 inherit; cursor:pointer; }
.vtoggle button.on { background:var(--teal); color:#fff; font-weight:700; }
/* ── List view ── */
.grid.list { display:block; }
.grid.list .card { display:flex; flex-direction:row; align-items:stretch; margin-bottom:6px; border-radius:6px; }
.grid.list .card .img { aspect-ratio:auto; width:72px; min-width:72px; height:72px; background-size:cover; background-position:center; flex:none; }
.grid.list .card .body { flex:1; padding:6px 10px; font-size:12px; display:flex; flex-direction:column; justify-content:center; gap:2px; }
.grid.list .card .acts { flex:none; align-self:center; flex-direction:column; gap:3px; padding:6px 8px 6px 0; }
.grid.list .card .acts .act { font-size:10px; padding:3px 6px; }
</style>
<link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
</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 A→Z</option>
<option value="sku">SKU A→Z</option>
<option value="style">Style A→Z</option>
<option value="material">Material A→Z</option>
<option value="price">Price ↑</option>
<option value="type">Type 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>
<div class="vtoggle">
<button id="vGrid" class="on" onclick="setView('grid')">▦ Grid</button>
<button id="vList" onclick="setView('list')">☰ List</button>
</div>
<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…" />
<input type="hidden" id="pattern" value="" /><!-- drill.js contract: pattern type-ahead input; zuber folds pattern search into #q, so this stays empty -->
<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 src="/drill.js"></script>
<script>
const $ = (s, r=document) => r.querySelector(s);
const API = location.origin;
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
// 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();
// ── /drill.js bridge (href-to-deeper-data primitives, adapted to multi-select) ──
// drill.js's drill()/qstr() are keyed by "field" = the URL param. Zuber's server reads
// api-keys (product_type/collection/color/style/material/price/tag/image), so FIELDS keys
// by api-key and we keep an api-key→state-key reverse map. drill(apiKey,val,label) emits an
// <a href="?apiKey=val"> deep-link; a plain click ADDS val to that facet's Set (drill deeper),
// modifier/middle click keeps the real shareable href. qstr/readURL/writeURL/setF are
// overridden below to speak the '|'-joined multi-select URL the API already understands.
const FIELDS = FACET_DEFS.map(([stateKey, apiKey]) => [apiKey, CHIP_LABEL[stateKey]]);
const API_TO_STATE = Object.fromEntries(FACET_DEFS.map(([stateKey, apiKey]) => [apiKey, stateKey]));
const API_FOR = Object.fromEntries(FACET_DEFS.map(([stateKey, apiKey]) => [stateKey, apiKey]));
// URL a left-rail facet count links to = current multi-select state with `val` TOGGLED for that
// facet (so the shareable link matches what a click produces). Built off the live Set state.
function facetToggleHref(stateKey, apiKey, val) {
const p = new URLSearchParams();
for (const [sk, ak] of FACET_DEFS) {
const s = new Set(state[sk]);
if (sk === stateKey) { s.has(String(val)) ? s.delete(String(val)) : s.add(String(val)); }
if (s.size) p.set(ak, [...s].join('|'));
}
if (state.q) p.set('q', state.q);
if (state.sort && state.sort !== 'newest') p.set('sort', state.sort);
const qs = p.toString(); return qs ? ('?' + qs) : location.pathname;
}
// FIL is a live view of state, one representative value per api-key (drill.js reads FIL[field]
// only to seed the href's other params — the multi-select qstr override rebuilds the full URL).
const FIL = new Proxy({}, {
get(_t, apiKey) { const sk = API_TO_STATE[apiKey]; const s = sk && state[sk]; return s && s.size ? [...s].join('|') : ''; },
has(_t, apiKey) { return apiKey in API_TO_STATE; },
});
// Build the full multi-select URL from current Set state, plus any override from drill.js's
// drill() (it passes {...FIL,[field]:val} so the atom's own value is UNIONED into that facet →
// the rendered <a href> is a real "current filters + this value" deep-link).
function qstr(override, q) {
const p = new URLSearchParams();
for (const [stateKey, apiKey] of FACET_DEFS) {
const s = new Set(state[stateKey]);
if (override && override[apiKey]) for (const v of String(override[apiKey]).split('|').filter(Boolean)) s.add(v);
if (s.size) p.set(apiKey, [...s].join('|'));
}
const qv = (q != null ? q : state.q);
if (qv) p.set('q', qv);
if (state.sort && state.sort !== 'newest') p.set('sort', state.sort);
const qs = p.toString(); return qs ? ('?' + qs) : location.pathname;
}
// Read multi-select filter state from the URL into the Sets + inputs (deep-link / back-button).
function readURL() {
const u = new URLSearchParams(location.search);
for (const [stateKey, apiKey] of FACET_DEFS) {
const v = u.get(apiKey);
state[stateKey] = new Set(v ? v.split('|').filter(Boolean) : []);
}
state.q = u.get('q') || ''; $('#q').value = state.q; save('q', state.q);
state.sort = u.get('sort') || 'newest'; $('#sort').value = state.sort; save('sort', state.sort);
persistFacets();
}
// Reflect current filter state into the URL (push = new history entry, else replace).
function writeURL(push) { history[push ? 'pushState' : 'replaceState']({}, '', qstr()); }
// Atom drill target: ADD this value to the facet's Set (drill into deeper data), then re-filter.
function setF(apiKey, v) {
const sk = API_TO_STATE[apiKey]; if (!sk) return;
if (state[sk].has(String(v))) return; // already drilled in — no-op
state[sk].add(String(v));
applyFilterChange();
}
// Republish onto window so drill.js's drill() closes over THESE overrides (drill.js set them first).
window.qstr = qstr; window.readURL = readURL; window.writeURL = writeURL; window.setF = setF;
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); writeURL(false); load(); };
$('#q').oninput = e => { state.q = e.target.value; save('q', e.target.value); clearTimeout(window._t); window._t = setTimeout(() => applyFilterChange('push'), 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'); }
// ── Grid / List view toggle ──
function setView(v) {
const g = document.querySelector('.grid#grid');
g.classList.toggle('list', v === 'list');
document.getElementById('vGrid').classList.toggle('on', v !== 'list');
document.getElementById('vList').classList.toggle('on', v === 'list');
save('view', v);
}
(function initView() { setView(ls('view', 'grid')); })();
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(nav) { persistFacets(); writeURL(nav === 'push' ? true : false); 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; }
const apiKey = API_FOR[stateKey];
shown.forEach(([val, ct]) => {
// Each facet count is a real, shareable href to its filtered (deeper) view. The anchor's
// href reflects the POST-click multi-select state (this value toggled into/out of the set);
// a plain click toggles in place, cmd/ctrl/middle-click opens the filtered URL in a new tab.
const el = document.createElement('a');
el.className = 'fitem drill' + (set.has(val) ? ' on' : '') + (ct ? '' : ' zero');
el.href = facetToggleHref(stateKey, apiKey, val);
el.dataset.filter = apiKey; el.dataset.v = val;
let swatch = '';
if (opts.swatch && opts.swatch[val]) { const bg = opts.swatch[val]; swatch = `<span class="swatch" style="background:${esc(bg)}"></span>`; }
el.innerHTML = `<span class="box">${set.has(val) ? '✓' : ''}</span>${swatch}<span class="lbl" title="${esc(val)}">${esc(val)}</span><span class="ct">${ct.toLocaleString()}</span>`;
el.addEventListener('click', (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button === 1) return; // keep real href
e.preventDefault(); 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';
// drill only the EXISTING facet-valued atom already shown on the card — the collection
// (Book/Series). No new text/chips introduced; other facets live in the left rail.
const collA = r.collection ? drill('collection', r.collection, r.collection) : '';
const metaParts = [collA, r.width ? esc(r.width) : ''].filter(Boolean).join(' · ');
const frLine = (r.pattern_name_fr && r.pattern_name_fr !== r.pattern_name) ? `<div class="fr">${esc(r.pattern_name_fr)}</div>` : '';
body.innerHTML = `
<div class="pat">${esc(r.pattern_name || r.mfr_sku)}</div>
${frLine}
<div class="meta">${esc(r.mfr_sku)}${metaParts ? ' — ' + metaParts : ''}</div>
<div class="when" title="${esc(r.created_at || '')}">🕓 ${fmtDate(r.created_at)}</div>
${r.product_url ? `<a href="${esc(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));
}
// Deep-link / back-button: seed filter state from the URL (falls back to persisted localStorage
// when the URL carries no filters), then render. Every filter change rewrites the URL (writeURL).
if (location.search) readURL();
refreshFacets().then(load);
setInterval(load, 60000);
addEventListener('popstate', () => { readURL(); refreshFacets(); load(); });
// ── Card facet atoms (drill) → drill INTO deeper data: a plain click adds that value to its facet
// Set; cmd/ctrl/shift/middle-click keeps the real shareable href. Uses setF from /drill.js. ──
$('#grid').addEventListener('click', (e) => {
const d = e.target.closest('a.drill'); if (!d) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button === 1) return; // keep real href
e.preventDefault(); e.stopPropagation();
setF(d.dataset.filter, d.dataset.v);
});
// ── 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>
<script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
</body>
</html>