← back to Gracie Internal

public/index.html

414 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Gracie — Internal Line Viewer</title>
<style>
  :root { --cardmin: 230px; --stroke:#e4e0d8; --ink:#1c1a17; --mut:#8a857c; --accent:#0f766e; --bg:#faf8f4; --card:#fbf9f5; }
  * { box-sizing: border-box; }
  body { margin:0; font-family: ui-sans-serif, -apple-system, "Segoe UI", Roboto, sans-serif; color:var(--ink); background:var(--bg); }
  header { display:flex; align-items:center; gap:16px; padding:14px 20px; border-bottom:1px solid var(--stroke); background:#fff; position:sticky; top:0; z-index:20; }
  header .brand { font-family: Georgia, "Times New Roman", serif; font-size:20px; letter-spacing:.02em; }
  header .brand small { color:var(--mut); font-size:12px; letter-spacing:.14em; text-transform:uppercase; display:block; }
  header .count { color:var(--mut); font-size:13px; }
  .controls { margin-left:auto; display:flex; align-items:center; gap:16px; flex-wrap:wrap; }
  .controls label { font-size:12px; color:var(--mut); display:flex; align-items:center; gap:6px; }
  select, input[type=search] { font:inherit; font-size:13px; padding:6px 8px; border:1px solid var(--stroke); border-radius:6px; background:#fff; }

  /* ── layout: Amazon-style left rail + content (ported from all.designerwallcoverings) ── */
  .wrap { display:flex; align-items:flex-start; }
  aside#rail { width:280px; flex:0 0 280px; position:sticky; top:57px; max-height:calc(100vh - 57px);
    overflow-y:auto; padding:12px 12px 60px; border-right:1px solid var(--stroke); background:#fff; }
  aside#rail input[type=search] { width:100%; margin-bottom:10px; }

  /* active-filter chip row above the sections */
  .active { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:8px; }
  .afilter { background:#eef4f3; border:1px solid var(--accent); color:var(--ink); border-radius:20px; padding:3px 9px; font-size:11px; cursor:pointer; }
  .afilter b { color:var(--accent); font-weight:600; }
  .clearall { background:none; border:1px solid var(--stroke); color:var(--mut); border-radius:20px; padding:3px 11px; font-size:11px; cursor:pointer; }

  /* Astek-style collapsed accordion sections per field */
  details.sec { border:1px solid var(--stroke); border-radius:7px; margin-bottom:7px; background:var(--card); }
  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; }
  details.sec > summary::-webkit-details-marker { display:none; }
  details.sec > summary::before { content:'▸'; color:var(--accent); font-size:10px; }
  details.sec[open] > summary::before { content:'▾'; }
  details.sec > summary .n { margin-left:auto; background:var(--accent); color:#fff; border-radius:20px;
    padding:0 7px; font-size:10px; font-weight:700; }
  .fbody { padding:2px 8px 9px; }
  .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:#f2efe9; }
  .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(--accent); border-color:var(--accent); color:#fff; font-weight:700; }
  .fitem.on { color:var(--accent); }
  .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; }
  .fitem .dot { width:12px; height:12px; flex:0 0 12px; border-radius:3px; border:1px solid rgba(0,0,0,.18); }
  .more { color:var(--accent); 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(--stroke); border-radius:6px !important; margin:2px 0 5px; background:#fff; }
  /* Card-fields toggles reuse .fitem chrome */
  #fieldsRow .fitem .box { border-radius:3px; }

  main { flex:1; min-width:0; padding:16px 20px 60px; }
  .grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(var(--cardmin), 1fr)); gap:14px; }
  .card { border:1px solid var(--stroke); border-radius:10px; overflow:hidden; background:#fff; display:flex; flex-direction:column; }
  .card .imgwrap { aspect-ratio: 3/4; background:#f0ece4; overflow:hidden; }
  .card img { width:100%; height:100%; object-fit:cover; display:block; }
  .card .body { padding:9px 10px 11px; display:flex; flex-direction:column; gap:4px; }
  .card .ptn { font-size:13.5px; font-weight:600; line-height:1.2; }
  .card .meta { font-size:11.5px; color:var(--mut); }
  .card .tags { display:flex; flex-wrap:wrap; gap:4px; margin-top:3px; }
  .card .tag { font-size:10px; background:#f2efe9; border-radius:8px; padding:1px 7px; color:#5a554c; }
  .card .when { font-size:10.5px; color:var(--mut); display:flex; align-items:center; gap:4px; margin-top:2px; }
  .card .flags { display:flex; gap:5px; flex-wrap:wrap; }
  .flag { font-size:9.5px; text-transform:uppercase; letter-spacing:.04em; border-radius:6px; padding:1px 6px; }
  .flag.gate { background:#fde2e2; color:#a12; }
  .flag.enr { background:#e2f0ec; color:var(--accent); }
  .swatch { width:11px; height:11px; border-radius:3px; border:1px solid rgba(0,0,0,.15); display:inline-block; vertical-align:-1px; margin-right:4px; }
  /* card-field visibility toggles (hidden classes set on body) */
  body.hide-meta .card .meta { display:none; }
  body.hide-flags .card .flags { display:none; }
  body.hide-tags .card .tags { display:none; }
  body.hide-when .card .when { display:none; }
  body.hide-ptn .card .ptn { display:none; }
  /* purchasing actions on each card: Memo / Stock / Price → /api/request */
  .card .acts { display:flex; gap:4px; padding:0 10px 10px; }
  .card .acts .act { flex:1; font:inherit; font-size:10.5px; color:var(--mut); background:transparent; border:1px solid var(--stroke); border-radius:6px; padding:5px 2px; cursor:pointer; }
  .card .acts .act:hover { border-color:var(--accent); color:var(--accent); }
  .card .acts .act:disabled { opacity:.5; cursor:default; }
  body.imgonly .card .acts { display:none; }
  #toast { position:fixed; left:50%; bottom:20px; transform:translateX(-50%) translateY(20px); max-width:min(560px,92vw);
    background:#fff; color:var(--ink); border:1px solid var(--stroke); border-radius:9px; padding:11px 17px; font-size:13px;
    box-shadow:0 10px 34px rgba(28,26,23,.18); opacity:0; pointer-events:none; transition:.25s; z-index:60; }
  #toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
  #toast.ok { border-color:var(--accent); }
  #toast.err { border-color:#c0392b; }
  /* image-only mode = collapse the entire card body → pure texture wall */
  body.imgonly .card .body { display:none; }
  body.imgonly .grid { gap:6px; }
  .grid { font-size: calc(11px + (var(--cardmin) - 160px) * 0.012); }
  @media (max-width:760px){ aside#rail { position:static; width:100%; flex-basis:auto; max-height:none; border-right:none; border-bottom:1px solid var(--stroke); } .wrap { flex-direction:column; } }
</style>
</head>
<body>
<header>
  <div class="brand">Gracie<small>Internal Line Viewer</small></div>
  <div class="count" id="count">…</div>
  <div class="controls">
    <label>Sort
      <select id="sort">
        <option value="newest">Newest</option>
        <option value="collection">Book / Series</option>
        <option value="pattern">Pattern A→Z</option>
        <option value="sku">SKU A→Z</option>
        <option value="color">Color</option>
      </select>
    </label>
    <label>Density
      <input type="range" id="density" min="140" max="380" step="10" value="230" />
    </label>
    <label><input type="checkbox" id="imgonly" /> Image only</label>
  </div>
</header>
<div class="wrap">
  <aside id="rail">
    <input type="search" id="search" placeholder="Search pattern / SKU / tag…" autocomplete="off" />
    <div class="active" id="activeRow"></div>
    <details class="sec" data-k="fields"><summary>Card fields<span class="n" id="nFields"></span></summary><div class="fbody" id="fieldsRow"></div></details>
    <details class="sec" data-k="type"><summary>Type<span class="n" id="nType"></span></summary><div class="fbody" id="typeRow"></div></details>
    <details class="sec" data-k="series"><summary>Book / Series<span class="n" id="nSeries"></span></summary><div class="fbody" id="seriesRow"></div></details>
    <details class="sec" data-k="color"><summary>Color<span class="n" id="nColor"></span></summary><div class="fbody" id="colorRow"></div></details>
    <details class="sec" data-k="style"><summary>Style<span class="n" id="nStyle"></span></summary><div class="fbody" id="styleRow"></div></details>
    <details class="sec" data-k="material"><summary>Material<span class="n" id="nMaterial"></span></summary><div class="fbody" id="matRow"></div></details>
    <details class="sec" data-k="price"><summary>Price<span class="n" id="nPrice"></span></summary><div class="fbody" id="priceRow"></div></details>
    <details class="sec" data-k="tag"><summary>Tags<span class="n" id="nTag"></span></summary><div class="fbody" id="tagRow"></div></details>
    <details class="sec" data-k="image"><summary>Image<span class="n" id="nImage"></span></summary><div class="fbody" id="imgRow"></div></details>
  </aside>
  <main>
    <div class="grid" id="grid"></div>
  </main>
</div>
<script>
const ORIGIN = location.origin;
let ALL = [], FACETS = {}, SEARCH = '';
const grid = document.getElementById('grid');
const fmtDate = (iso) => { try { return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); } catch { return iso; } };
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c]));

/* ── filter state — one Set per facet dimension, persisted to localStorage ── */
const loadSet = (k) => { try { return new Set(JSON.parse(localStorage.getItem(k) || '[]')); } catch { return new Set(); } };
const state = {
  types: loadSet('gr_types'), series: loadSet('gr_series'), colors: loadSet('gr_colors'),
  styles: loadSet('gr_styles'), materials: loadSet('gr_materials'), tags: loadSet('gr_tags'),
  prices: loadSet('gr_prices'), images: loadSet('gr_images'),
};
const SETS = { types:'gr_types', series:'gr_series', colors:'gr_colors', styles:'gr_styles', materials:'gr_materials', tags:'gr_tags', prices:'gr_prices', images:'gr_images' };
function persistFacets() { for (const [k, key] of Object.entries(SETS)) localStorage.setItem(key, JSON.stringify([...state[k]])); }

/* per-row UI state: expanded? + type-ahead needle (big rows get a find box) */
const rowUI = {
  typeRow: { expand:false, find:'' }, seriesRow: { expand:false, find:'' },
  colorRow: { expand:false, find:'' }, styleRow: { expand:false, find:'' },
  matRow: { expand:false, find:'' }, tagRow: { expand:false, find:'', typeahead:true },
  priceRow: { expand:false, find:'' }, imgRow: { expand:false, find:'' },
};

/* card-field visibility toggles (image-only spectrum) */
const CARD_FIELDS = [
  { key:'ptn', label:'Pattern name', cls:'hide-ptn' },
  { key:'meta', label:'SKU · book', cls:'hide-meta' },
  { key:'flags', label:'Flags', cls:'hide-flags' },
  { key:'tags', label:'Tags', cls:'hide-tags' },
  { key:'when', label:'Created date', cls:'hide-when' },
];
function renderCardFields() {
  const row = document.getElementById('fieldsRow'); row.innerHTML = '';
  for (const f of CARD_FIELDS) {
    const on = localStorage.getItem('gr_cf_' + f.key) !== '0'; // default shown
    document.body.classList.toggle(f.cls, !on);
    const el = document.createElement('div');
    el.className = 'fitem' + (on ? ' on' : '');
    el.innerHTML = `<span class="box">${on ? '✓' : ''}</span><span class="lbl">${f.label}</span>`;
    el.onclick = () => { const nv = localStorage.getItem('gr_cf_' + f.key) === '0'; localStorage.setItem('gr_cf_' + f.key, nv ? '1' : '0'); renderCardFields(); };
    row.appendChild(el);
  }
  document.getElementById('nFields').textContent = CARD_FIELDS.filter(f => localStorage.getItem('gr_cf_' + f.key) === '0').length || '';
  document.getElementById('nFields').style.display = CARD_FIELDS.some(f => localStorage.getItem('gr_cf_' + f.key) === '0') ? '' : 'none';
}

async function boot() {
  const [p, f] = await Promise.all([
    fetch(ORIGIN + '/api/products').then(r => r.json()),
    fetch(ORIGIN + '/api/facets').then(r => r.json()),
  ]);
  ALL = p.products; FACETS = f;
  renderCardFields(); renderFacets(); render();
}

/* does product p match value val on facet dimension key? */
function facetMatch(p, key, val) {
  switch (key) {
    case 'types': return p.product_type === val;
    case 'series': return p.collection === val;
    case 'colors': return productFamilies(p).includes(val);
    case 'styles': return (p.styles || []).includes(val);
    case 'materials': return (p.material || 'Unspecified') === val;
    case 'tags': return (p.tags || []).includes(val);
    case 'prices': return val === 'Quote-only'; // Gracie is quote-only across the board
    case 'images': return (p.image_url ? 'Has image' : 'No image') === val;
    default: return true;
  }
}

/* client-side color-family bucketing — MUST match the server's familyOf() exactly */
function hexToRgb(hex){ const m=String(hex||'').trim().replace(/^#/,''); if(!/^[0-9a-fA-F]{6}$/.test(m))return null; return [parseInt(m.slice(0,2),16),parseInt(m.slice(2,4),16),parseInt(m.slice(4,6),16)]; }
function rgbToHsl(r,g,b){ r/=255;g/=255;b/=255; const mx=Math.max(r,g,b),mn=Math.min(r,g,b); let h=0,s=0; const l=(mx+mn)/2; if(mx!==mn){const d=mx-mn;s=l>0.5?d/(2-mx-mn):d/(mx+mn);switch(mx){case r:h=(g-b)/d+(g<b?6:0);break;case g:h=(b-r)/d+2;break;case b:h=(r-g)/d+4;break;}h/=6;} return [h*360,s*100,l*100]; }
function familyOf(hex){
  const rgb=hexToRgb(hex); if(!rgb) return null;
  const [h,s,l]=rgbToHsl(...rgb);
  if(l<=10) return 'Black';
  if(l>=94 && s<=8) return 'White';
  if(s<=10){ if(l>=86) return 'White'; return 'Gray'; }
  if(s<=25 && l>=84) return 'Cream';
  if((h<52||h>=345) && l<=32 && !(h>300&&h<345)) return 'Brown';
  if(h>=18 && h<52){ if(l<=42)return 'Brown'; if(s<=38&&l>=82)return 'Cream'; if(s<=55&&l>=60)return 'Beige'; if(s>=45&&l>=42&&l<=72)return 'Gold'; if(l<=60)return 'Brown'; return 'Beige'; }
  if(h>=52 && h<66) return 'Yellow';
  if((h<15||h>=340) && (l>=72||(s<=60&&l>=60))) return 'Pink';
  if(h<15||h>=345) return 'Red';
  if(h<18) return 'Orange';
  if(h<160) return 'Green';
  if(h<200) return 'Teal';
  if(h<255) return 'Blue';
  if(h<290) return 'Purple';
  if(h<340) return 'Pink';
  return 'Red';
}
function productFamilies(p){
  const fams=new Set();
  for(const c of (p.colors||[])){ const f=familyOf(c&&c.hex); if(f)fams.add(f); }
  if(!fams.size && p.color_hex){ const f=familyOf(p.color_hex); if(f)fams.add(f); }
  return [...fams];
}

/* ── the ported facet list: checkbox rows + counts, type-ahead on big rows, cap/expand ── */
function toggleSet(set, val) { set.has(val) ? set.delete(val) : set.add(val); persistFacets(); renderFacets(); render(); }

function facetList(rowId, counts, set, opts = {}) {
  const row = document.getElementById(rowId); row.innerHTML = '';
  const ui = rowUI[rowId];
  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(); const f2 = document.querySelector(`#${rowId} .ffind`); if (f2) { f2.focus(); f2.setSelectionRange(f2.value.length, f2.value.length); } };
    row.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:var(--mut);padding:2px'; s.textContent = 'none'; row.appendChild(s); return; }
  shown.forEach(([val, ct]) => {
    const el = document.createElement('div');
    el.className = 'fitem' + (set.has(val) ? ' on' : '') + (ct ? '' : ' zero');
    const dot = opts.swatch && opts.swatch[val] ? `<span class="dot" style="background:${esc(opts.swatch[val])}"></span>` : '';
    el.innerHTML = `<span class="box">${set.has(val) ? '✓' : ''}</span>${dot}<span class="lbl" title="${esc(val)}">${esc(val)}</span><span class="ct">${ct.toLocaleString()}</span>`;
    el.onclick = () => toggleSet(set, val);
    row.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(); };
    row.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(); };
    row.appendChild(el);
  }
}

function renderFacets() {
  facetList('typeRow', FACETS.type, state.types, { cap: 10 });
  facetList('seriesRow', FACETS.series, state.series, { cap: 12 });
  facetList('colorRow', FACETS.color, state.colors, { order: FACETS.family_order, swatch: FACETS.color_swatch, cap: 12 });
  facetList('styleRow', FACETS.style, state.styles, { cap: 10 });
  facetList('matRow', FACETS.material, state.materials, { cap: 10 });
  facetList('priceRow', FACETS.price_band, state.prices, { order: FACETS.price_order, cap: 4 });
  facetList('tagRow', FACETS.tag, state.tags, { cap: 12 });
  facetList('imgRow', FACETS.image_state, state.images, { cap: 4 });
  renderActive();
}

const SECN = { Type:'nType', Book:'nSeries', Color:'nColor', Style:'nStyle', Material:'nMaterial', Price:'nPrice', Tag:'nTag', Image:'nImage' };
function renderActive() {
  const row = document.getElementById('activeRow'); row.innerHTML = '';
  const dims = [['Type', state.types], ['Book', state.series], ['Color', state.colors], ['Style', state.styles],
    ['Material', state.materials], ['Price', state.prices], ['Tag', state.tags], ['Image', state.images]];
  for (const [label, set] of dims) { // selection-count badge on each collapsed section header
    const b = document.getElementById(SECN[label]);
    if (b) { b.textContent = set.size || ''; b.style.display = set.size ? '' : 'none'; }
  }
  let n = 0;
  for (const [label, set] of dims) {
    set.forEach((v) => {
      n++;
      const el = document.createElement('span'); el.className = 'afilter';
      el.innerHTML = `<b>${label}:</b> ${esc(v)} ✕`;
      el.onclick = () => { set.delete(v); persistFacets(); renderFacets(); render(); };
      row.appendChild(el);
    });
  }
  if (n) {
    const c = document.createElement('button'); c.className = 'clearall'; c.textContent = 'Clear all';
    c.onclick = () => { dims.forEach(([, s]) => s.clear()); persistFacets(); renderFacets(); render(); };
    row.appendChild(c);
  }
}

function filtered() {
  let rows = ALL.filter((p) => {
    for (const key of Object.keys(SETS)) {
      const set = state[key];
      if (set.size && ![...set].some((v) => facetMatch(p, key, v))) return false;
    }
    if (SEARCH) {
      const hay = (p.pattern_name + ' ' + p.mfr_sku + ' ' + p.collection + ' ' + (p.tags || []).join(' ') + ' ' + (p.styles || []).join(' ')).toLowerCase();
      if (!hay.includes(SEARCH)) return false;
    }
    return true;
  });
  const s = document.getElementById('sort').value;
  const fam = (p) => { const f = productFamilies(p)[0]; const i = f ? (FACETS.family_order || []).indexOf(f) : -1; return i < 0 ? 99 : i; };
  const cmp = {
    newest: (a, b) => (b.created_at || '').localeCompare(a.created_at || ''),
    collection: (a, b) => (a.collection || '').localeCompare(b.collection || '') || (a.pattern_name || '').localeCompare(b.pattern_name || ''),
    pattern: (a, b) => (a.pattern_name || '').localeCompare(b.pattern_name || ''),
    sku: (a, b) => (a.mfr_sku || '').localeCompare(b.mfr_sku || ''),
    color: (a, b) => fam(a) - fam(b) || (a.mfr_sku || '').localeCompare(b.mfr_sku || ''),
  }[s];
  return rows.sort(cmp);
}

function render() {
  renderActive();
  const rows = filtered();
  document.getElementById('count').textContent = `${rows.length} of ${ALL.length} patterns`;
  grid.innerHTML = '';
  const frag = document.createDocumentFragment();
  for (const p of rows) {
    const card = document.createElement('div'); card.className = 'card';
    card.dataset.sku = p.dw_sku || '';
    card.dataset.mfr = p.mfr_sku || '';
    card.dataset.title = p.pattern_name || p.mfr_sku || '';
    const img = p.image_url ? `<img loading="lazy" src="${esc(p.image_url)}?w=500&q=70" alt="${esc(p.pattern_name)}">` : '';
    const flags = [];
    if (p.settlement_flag && p.settlement_flag !== 'OK') flags.push(`<span class="flag gate">gate: ${esc(p.settlement_flag)}</span>`);
    if (p.enriched) flags.push(`<span class="flag enr">enriched</span>`);
    const sw = p.color_hex ? `<span class="swatch" style="background:${esc(p.color_hex)}"></span>` : '';
    const tags = (p.tags || []).slice(0, 5).map((t) => `<span class="tag">${esc(t)}</span>`).join('');
    card.innerHTML = `
      <div class="imgwrap">${img}</div>
      <div class="body">
        <div class="ptn">${esc(p.pattern_name)}</div>
        <div class="meta">${sw}${esc(p.mfr_sku)} · ${esc(p.collection)}</div>
        <div class="flags">${flags.join('')}</div>
        <div class="tags">${tags}</div>
        <div class="when" title="${esc(p.created_at)}">🕓 ${fmtDate(p.created_at)}</div>
      </div>
      <div class="acts">
        <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>
      </div>`;
    frag.appendChild(card);
  }
  grid.appendChild(frag);
}

/* ── 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);
}

/* ── top-bar controls (search + sort + density + image-only, all persisted) ── */
document.getElementById('sort').onchange = (e) => { localStorage.gracieSort = e.target.value; render(); };
document.getElementById('density').oninput = (e) => { document.documentElement.style.setProperty('--cardmin', e.target.value + 'px'); localStorage.gracieDensity = e.target.value; };
document.getElementById('imgonly').onchange = (e) => { document.body.classList.toggle('imgonly', e.target.checked); localStorage.gracieImgOnly = e.target.checked ? '1' : ''; };
document.getElementById('search').oninput = (e) => { SEARCH = e.target.value.trim().toLowerCase(); render(); };
/* restore prefs */
if (localStorage.gracieDensity) { document.getElementById('density').value = localStorage.gracieDensity; document.documentElement.style.setProperty('--cardmin', localStorage.gracieDensity + 'px'); }
if (localStorage.gracieImgOnly) { document.getElementById('imgonly').checked = true; document.body.classList.add('imgonly'); }
if (localStorage.gracieSort) { document.getElementById('sort').value = localStorage.gracieSort; }
/* accordion sections: persist open/closed per section (collapsed by default, Astek-style) */
document.querySelectorAll('details.sec').forEach((d) => {
  d.open = localStorage.getItem('gr_sec_' + d.dataset.k) === '1';
  d.ontoggle = () => localStorage.setItem('gr_sec_' + d.dataset.k, d.open ? '1' : '0');
});
boot();
</script>
</body>
</html>