← back to Fentucci Naturals

public/index.html

359 lines

<!doctype html><html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Fentucci Naturals — Internal Line Viewer</title>
<style>
  :root{--bg:#0e0f11;--card:#16181c;--panel:#16181c;--line:#2a2d33;--txt:#e8e6e1;--mut:#8b8f96;
        --accent:#3fb8af;--acc:#3fb8af;--teal:#3fb8af;--rail:290px;--cols:6;--fs:1;--scale:1;}
  *{box-sizing:border-box}
  body{margin:0;font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--txt)}
  a{color:var(--accent)}
  header{padding:12px 16px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:12px;flex-wrap:wrap;
    position:sticky;top:0;background:var(--bg);z-index:10}
  header h1{font-size:16px;margin:0;letter-spacing:.14em;font-weight:600;text-transform:uppercase;display:flex;align-items:center;gap:8px}
  header .meta{color:var(--mut);font-size:12px}
  header input[type=search]{flex:1;min-width:200px;padding:8px 10px;background:var(--panel);border:1px solid var(--line);color:var(--txt);border-radius:8px}
  .bar{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--mut);letter-spacing:.04em}
  .bar select{background:var(--panel);border:1px solid var(--line);color:var(--txt);border-radius:7px;padding:5px 7px;font-size:12px}
  .bar input[type=range]{width:120px}
  .quote{color:var(--teal);border:1px solid var(--teal);padding:3px 10px;border-radius:12px;font-size:11px;letter-spacing:.1em}
  .dirbtn{background:var(--panel);border:1px solid var(--line);color:var(--accent);border-radius:7px;padding:5px 9px;cursor:pointer;font-family:inherit}
  .dirbtn:hover{border-color:var(--accent)}
  #railToggle{display:none}

  /* ── layout: left rail + content ── */
  .wrap{display:flex;align-items:flex-start}
  aside{width:var(--rail);flex:0 0 var(--rail);position:sticky;top:57px;max-height:calc(100vh - 57px);
    overflow-y:auto;padding:10px 10px 60px;border-right:1px solid var(--line)}

  .active{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:6px}
  .afilter{background:#23211a;border:1px solid var(--accent);color:var(--txt);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(--line);color:var(--mut);border-radius:20px;padding:3px 11px;font-size:11px;cursor:pointer}
  .clearall:hover{border-color:var(--accent);color:var(--txt)}

  details.sec{border:1px solid var(--line);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:#0e0f11;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(--txt);border-radius:5px}
  .fitem:hover{background:#1d2026}
  .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:#0e0f11;font-weight:700}
  .fitem.on{color:var(--accent)}
  .fitem .swatch{width:12px;height:12px;flex:0 0 12px;border-radius:50%;border:1px solid #0006}
  .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(--accent);font-size:11.5px;cursor:pointer;padding:3px 2px;display:inline-block}
  .ffind{width:100%;padding:4px 8px;font-size:11.5px;border-radius:6px;margin:2px 0 5px;background:var(--panel);border:1px solid var(--line);color:var(--txt)}
  .fieldchip{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--mut);border:1px solid var(--line);
    border-radius:6px;padding:3px 8px;margin:2px 3px 2px 0;cursor:pointer;background:var(--panel)}
  .fieldchip.on{color:var(--accent);border-color:var(--accent)}

  main{flex:1;min-width:0;padding:14px}
  .grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:8px}
  .card{border:1px solid var(--line);border-radius:6px;overflow:hidden;background:var(--card);display:flex;flex-direction:column}
  .card .imgwrap{aspect-ratio:1;background:#000;overflow:hidden}
  .card img{width:100%;height:100%;object-fit:cover;display:block}
  .card .body{padding:calc(8px*var(--fs))}
  .card .acts{display:flex;gap:4px;padding:0 calc(8px*var(--fs)) calc(8px*var(--fs))}
  .card .acts .act{flex:1;font:inherit;font-size:calc(10px*var(--fs)*var(--scale));color:var(--mut);background:transparent;border:1px solid var(--line);border-radius:5px;padding:4px 2px;cursor:pointer}
  .card .acts .act:hover{border-color:var(--acc);color:var(--txt)}
  .card .acts .act:disabled{opacity:.5;cursor:default}
  body.imgonly .card .acts{display:none}
  #toast{position:fixed;left:50%;bottom:18px;transform:translateX(-50%) translateY(20px);max-width:min(560px,92vw);
    background:#1b1e24;color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:10px 16px;font-size:13px;
    box-shadow:0 8px 30px #0008;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:#7a2a2a}
  .card .nm{font-size:calc(13px*var(--fs)*var(--scale));font-weight:600;line-height:1.25;margin-bottom:3px;color:var(--txt)}
  .card .sub{font-size:calc(11px*var(--fs)*var(--scale));color:var(--mut)}
  .card .when{font-size:calc(10px*var(--fs)*var(--scale));color:var(--mut);margin-top:5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .card .dot{display:inline-block;width:10px;height:10px;border-radius:50%;border:1px solid #0006;vertical-align:middle;margin-right:5px}
  .card .tags{margin-top:5px;display:flex;flex-wrap:wrap;gap:3px}
  .card .tag{font-size:calc(9px*var(--fs)*var(--scale));background:#1d2026;color:var(--mut);padding:1px 6px;border-radius:8px}
  body.imgonly .card .body{display:none}
  .empty{color:var(--mut);padding:40px;text-align:center}

  @media(max-width:900px){
    #railToggle{display:inline-block}
    aside{position:fixed;left:0;top:57px;bottom:0;background:var(--bg);z-index:20;transform:translateX(-100%);transition:transform .2s}
    aside.open{transform:none;box-shadow:6px 0 30px rgba(0,0,0,.5)}
  }
</style></head><body>
<header>
  <h1>Fentucci Naturals <span class="quote">QUOTE-ONLY · $4.25 SAMPLE</span></h1>
  <button class="dirbtn" id="railToggle">☰ Filters</button>
  <span class="meta" id="meta">loading…</span>
  <input type="search" id="q" placeholder="Search pattern / sku / color / material / description…">
  <span class="bar">Sort
    <select id="sort">
      <option value="newest">Newest</option>
      <option value="title">Title A→Z</option>
      <option value="sku">SKU A→Z</option>
      <option value="series">Series</option>
      <option value="color">Color (family)</option>
      <option value="material">Material</option>
      <option value="style">Style (tag)</option>
    </select>
    <button class="dirbtn" id="dir" title="Flip sort direction">▲</button>
  </span>
  <span class="bar">Density <input type="range" id="dens" min="2" max="14" value="6"> <span id="densN"></span></span>
  <span class="bar">Text <input type="range" id="tscale" min="80" max="150" value="100"></span>
  <label class="bar"><input type="checkbox" id="imgonly"> Image-only wall</label>
</header>

<div class="wrap">
  <aside id="rail">
    <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="material"><summary>Material<span class="n" id="nMaterial"></span></summary><div class="fbody" id="matRow"></div></details>
    <details class="sec" data-k="pattern"><summary>Pattern<span class="n" id="nPattern"></span></summary><div class="fbody" id="patternRow"></div></details>
    <details class="sec" data-k="series"><summary>Series (mfr)<span class="n" id="nSeries"></span></summary><div class="fbody" id="seriesRow"></div></details>
    <details class="sec" data-k="category"><summary>Category<span class="n" id="nCategory"></span></summary><div class="fbody" id="catRow"></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="colorname"><summary>Color name<span class="n" id="nColorName"></span></summary><div class="fbody" id="colorNameRow"></div></details>
    <details class="sec" data-k="tags"><summary>Tags<span class="n" id="nTags"></span></summary><div class="fbody" id="tagsRow"></div></details>
    <details class="sec" data-k="settlement"><summary>Settlement<span class="n" id="nSettlement"></span></summary><div class="fbody" id="setRow"></div></details>
    <details class="sec" data-k="status"><summary>Status<span class="n" id="nStatus"></span></summary><div class="fbody" id="statusRow"></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="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>
    <div class="empty" id="empty" style="display:none">No matches.</div>
  </main>
</div>
<script>
const API = location.origin;
let ALL = [], LAST_FACETS = {};
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c]));
const fmtDate = (d) => d ? new Date(d).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}) : '';

/* ── persisted multi-select facet state ── */
const SETS = { materials:'ftc_materials', patterns:'ftc_patterns', series:'ftc_series', categories:'ftc_categories',
  colors:'ftc_colors', colorNames:'ftc_colorNames', tags:'ftc_tags', settlements:'ftc_settlements',
  statuses:'ftc_statuses', prices:'ftc_prices', images:'ftc_images' };
const loadSet = (k) => { try { return new Set(JSON.parse(localStorage.getItem(k)||'[]')); } catch { return new Set(); } };
const state = {
  q: '', sort: localStorage.getItem('ftc_sort')||'newest', dir: localStorage.getItem('ftc_dir')||'asc',
  materials:loadSet(SETS.materials), patterns:loadSet(SETS.patterns), series:loadSet(SETS.series),
  categories:loadSet(SETS.categories), colors:loadSet(SETS.colors), colorNames:loadSet(SETS.colorNames),
  tags:loadSet(SETS.tags), settlements:loadSet(SETS.settlements), statuses:loadSet(SETS.statuses),
  prices:loadSet(SETS.prices), images:loadSet(SETS.images),
};
function persistFacets(){ for(const [k,key] of Object.entries(SETS)) localStorage.setItem(key, JSON.stringify([...state[k]])); }
const rowUI = {
  matRow:{expand:false,find:''}, patternRow:{expand:false,find:'',typeahead:true}, seriesRow:{expand:false,find:'',typeahead:true},
  catRow:{expand:false,find:''}, colorRow:{expand:false,find:''}, colorNameRow:{expand:false,find:'',typeahead:true},
  tagsRow:{expand:false,find:'',typeahead:true}, setRow:{expand:false,find:''}, statusRow:{expand:false,find:''},
  priceRow:{expand:false,find:''}, imgRow:{expand:false,find:''},
};

/* ── card-field visibility toggles (all off = image-only texture wall) ── */
const FIELDS = [['nm','Pattern'],['sub','Color · SKU'],['dot','Color dot'],['mat','Material'],['tags','Tags'],['when','Date']];
let fieldOn = (()=>{ try{ return JSON.parse(localStorage.getItem('ftc_fields')); }catch{} return null; })() || {nm:1,sub:1,dot:1,mat:1,tags:1,when:1};
function renderFieldChips(){
  const box=document.getElementById('fieldsRow'); box.innerHTML='';
  for(const [k,label] of FIELDS){
    const el=document.createElement('span'); el.className='fieldchip'+(fieldOn[k]?' on':'');
    el.innerHTML=`<span class="box" style="width:11px;height:11px;border:1px solid var(--mut);border-radius:3px;display:inline-flex">${fieldOn[k]?'✓':''}</span>${label}`;
    el.onclick=()=>{ fieldOn[k]=fieldOn[k]?0:1; localStorage.setItem('ftc_fields',JSON.stringify(fieldOn)); renderFieldChips(); render(); };
    box.appendChild(el);
  }
  document.getElementById('nFields').textContent = Object.values(fieldOn).filter(Boolean).length;
}

/* ── sort ── */
const FAMILY_ORDER = ['Red','Orange','Brown','Yellow','Gold','Green','Teal','Blue','Purple','Pink','Beige','Cream','Black','Gray','White','Multi'];
function sortRows(rows){
  const str=(x)=>String(x||'');
  const by={
    newest:(a,b)=> new Date(b.created_at||0)-new Date(a.created_at||0) || (b.id-a.id),
    title:(a,b)=> str(a.pattern_name||a.mfr_sku).localeCompare(str(b.pattern_name||b.mfr_sku)),
    sku:(a,b)=> str(a.dw_sku||a.mfr_sku).localeCompare(str(b.dw_sku||b.mfr_sku)),
    series:(a,b)=> str(a.pattern_series).localeCompare(str(b.pattern_series)) || str(a.mfr_sku).localeCompare(str(b.mfr_sku)),
    color:(a,b)=>{ const i=(x)=>{ const c=(x.colors||[])[0]; const k=c?FAMILY_ORDER.indexOf(c):-1; return k<0?99:k; }; return i(a)-i(b) || str(a.mfr_sku).localeCompare(str(b.mfr_sku)); },
    material:(a,b)=> str(a.material).localeCompare(str(b.material)) || str(a.mfr_sku).localeCompare(str(b.mfr_sku)),
    style:(a,b)=> str((a.tags_clean||[])[0]||'zz').localeCompare(str((b.tags_clean||[])[0]||'zz')),
  };
  const out = rows.slice().sort(by[state.sort]||by.newest);
  return state.dir==='desc' ? out.reverse() : out;
}

/* ── data load ── */
async function load(){
  const [p,f] = await Promise.all([
    fetch(API+'/api/products').then(r=>r.json()),
    fetch(API+'/api/facets').then(r=>r.json()),
  ]);
  ALL = p.products;
  document.getElementById('meta').textContent = p.count+' colorways · '+new Set(ALL.map(x=>x.pattern_name)).size+' patterns';
  LAST_FACETS = f;
  renderFieldChips();
  renderFacets(f);
  render();
}

/* ── facet list rows: checkbox + count + type-ahead + cap/expand ── */
function toggleSet(set,val){ set.has(val)?set.delete(val):set.add(val); applyFilterChange(); }
function applyFilterChange(){ persistFacets(); renderFacets(LAST_FACETS); render(); }
function facetList(rowId, items, set, opts={}){
  const row=document.getElementById(rowId); row.innerHTML='';
  const ui=rowUI[rowId]||{expand:false,find:''};
  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=document.querySelector(`#${rowId} .ffind`); if(f2){ f2.focus(); f2.setSelectionRange(f2.value.length,f2.value.length); } };
    row.appendChild(find);
  }
  let entries=(items||[]).map(it=>[it.v,it.n,it.dot]);
  if(opts.order) entries.sort((a,b)=>opts.order.indexOf(a[0])-opts.order.indexOf(b[0]));
  if(ui.find) entries=entries.filter(([v])=>String(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'; row.appendChild(s); return; }
  shown.forEach(([val,ct,dot])=>{
    const el=document.createElement('div'); el.className='fitem'+(set.has(val)?' on':'')+(ct?'':' zero');
    const sw = dot ? `<span class="swatch" style="background:${dot}"></span>` : '';
    el.innerHTML=`<span class="box">${set.has(val)?'✓':''}</span>${sw}<span class="lbl" title="${esc(val)}">${esc(val)}</span><span class="ct">${(ct||0).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(LAST_FACETS); }; 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(LAST_FACETS); }; row.appendChild(el); }
}

function renderFacets(f){
  LAST_FACETS=f;
  facetList('matRow', f.material, state.materials, {cap:12});
  facetList('patternRow', f.pattern, state.patterns, {cap:12});
  facetList('seriesRow', f.series, state.series, {cap:10});
  facetList('catRow', f.category, state.categories, {cap:12});
  facetList('colorRow', f.color, state.colors, {order:f.family_order||FAMILY_ORDER, cap:16});
  facetList('colorNameRow', f.color_name, state.colorNames, {cap:10});
  facetList('tagsRow', f.tags, state.tags, {cap:14});
  facetList('setRow', f.settlement, state.settlements, {cap:4});
  facetList('statusRow', f.status, state.statuses, {cap:4});
  facetList('priceRow', f.price_band, state.prices, {cap:4});
  facetList('imgRow', f.image_state, state.images, {cap:4});
  renderActive();
}

/* ── active-filter chip row + per-section count badges ── */
const DIMS = [['Material','materials','nMaterial'],['Pattern','patterns','nPattern'],['Series','series','nSeries'],
  ['Category','categories','nCategory'],['Color','colors','nColor'],['Color name','colorNames','nColorName'],
  ['Tag','tags','nTags'],['Settlement','settlements','nSettlement'],['Status','statuses','nStatus'],
  ['Price','prices','nPrice'],['Image','images','nImage']];
function renderActive(){
  const row=document.getElementById('activeRow'); row.innerHTML='';
  for(const [,k,badgeId] of DIMS){ const b=document.getElementById(badgeId); if(b){ b.textContent=state[k].size||''; b.style.display=state[k].size?'':'none'; } }
  let n=0;
  for(const [label,k] of DIMS){
    state[k].forEach((v)=>{
      n++;
      const el=document.createElement('span'); el.className='afilter';
      el.innerHTML=`<b>${label}:</b> ${esc(v)} ✕`;
      el.onclick=()=>{ state[k].delete(v); applyFilterChange(); };
      row.appendChild(el);
    });
  }
  if(n){ const c=document.createElement('button'); c.className='clearall'; c.textContent='✕ Clear filters'; c.onclick=()=>{ DIMS.forEach(([,k])=>state[k].clear()); applyFilterChange(); }; row.appendChild(c); }
}

/* ── filter predicate ── */
function matches(p){
  const q=state.q;
  if(q){ const hay=[p.pattern_name,p.dw_sku,p.mfr_sku,p.pattern_series,p.color_name,p.material,p.source_category,p.description].filter(Boolean).join(' ').toLowerCase(); if(!hay.includes(q)) return false; }
  if(state.materials.size && !state.materials.has(p.material)) return false;
  if(state.patterns.size && !state.patterns.has(p.pattern_name)) return false;
  if(state.series.size && !state.series.has(p.pattern_series)) return false;
  if(state.categories.size && !state.categories.has(p.source_category)) return false;
  if(state.colors.size && !(p.colors||[]).some(c=>state.colors.has(c))) return false;
  if(state.colorNames.size && !state.colorNames.has(p.color_name)) return false;
  if(state.tags.size && !(p.tags_clean||[]).some(t=>state.tags.has(t))) return false;
  if(state.settlements.size && !state.settlements.has(p.settlement_status)) return false;
  if(state.statuses.size && !state.statuses.has(p.status)) return false;
  if(state.prices.size && !state.prices.has(p.price_band)) return false;
  if(state.images.size && !state.images.has(p.image_state)) return false;
  return true;
}

function render(){
  const grid=document.getElementById('grid'); const rows=sortRows(ALL.filter(matches));
  document.getElementById('empty').style.display=rows.length?'none':'block';
  grid.innerHTML = rows.map(p=>{
    const hex=p.color_hex;
    const tags=(p.tags_clean||[]).slice(0,4);
    const anyBody = fieldOn.nm||fieldOn.sub||fieldOn.mat||fieldOn.tags||fieldOn.when;
    const body = anyBody ? `<div class="body">
        ${fieldOn.nm?`<div class="nm">${esc(p.pattern_name||p.mfr_sku)} ${esc(p.color_name?'· '+p.color_name:'')}</div>`:''}
        ${fieldOn.sub?`<div class="sub">${fieldOn.dot&&hex?`<span class="dot" style="background:${esc(hex)}"></span>`:''}${esc(p.dw_sku||'')} · ${esc(p.mfr_sku||'')}</div>`:''}
        ${fieldOn.mat?`<div class="sub">${esc(p.material||'')}${p.source_category?' · '+esc(p.source_category):''}</div>`:''}
        ${fieldOn.tags&&tags.length?`<div class="tags">${tags.map(t=>`<span class="tag">${esc(t)}</span>`).join('')}</div>`:''}
        ${fieldOn.when?`<div class="when" title="${esc(p.created_at||'')}">🕓 ${fmtDate(p.created_at)}</div>`:''}
      </div>`:'';
    return `<div class="card" data-sku="${esc(p.dw_sku||'')}" data-mfr="${esc(p.mfr_sku||'')}" data-title="${esc((p.pattern_name||'')+(p.color_name?' '+p.color_name:''))}">
      <div class="imgwrap"><img loading="lazy" src="${esc(p.image||'')}" alt="${esc(p.pattern_name||'')}"></div>
      ${body}
      <div class="acts">
        <button class="act" data-act="memo"  title="Log a memo-sample request">Memo</button>
        <button class="act" data-act="stock" title="Check stock with the vendor">Stock</button>
        <button class="act" data-act="price" title="Get current price from the vendor">Price</button>
      </div></div>`;
  }).join('');
}

/* ── Purchasing actions: Memo / Stock / Price → /api/request ── */
document.getElementById('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 wiring ── */
const qEl=document.getElementById('q');
qEl.oninput=()=>{ state.q=qEl.value.trim().toLowerCase(); render(); };
const sortEl=document.getElementById('sort'); sortEl.value=state.sort;
sortEl.onchange=e=>{ state.sort=e.target.value; localStorage.setItem('ftc_sort',state.sort); render(); };
const dirBtn=document.getElementById('dir');
const drawDir=()=>{ dirBtn.textContent = state.dir==='desc'?'▼':'▲'; };
drawDir();
dirBtn.onclick=()=>{ state.dir=state.dir==='desc'?'asc':'desc'; localStorage.setItem('ftc_dir',state.dir); drawDir(); render(); };
const dens=document.getElementById('dens'), densN=document.getElementById('densN');
dens.value=localStorage.getItem('ftc_cols')||6;
const setCols=()=>{ document.documentElement.style.setProperty('--cols',dens.value); densN.textContent=dens.value; document.documentElement.style.setProperty('--fs', Math.max(.62,Math.min(1.15, 6/dens.value)).toFixed(2)); };
setCols();
dens.oninput=()=>{ setCols(); localStorage.setItem('ftc_cols',dens.value); };
const tscale=document.getElementById('tscale');
tscale.value=localStorage.getItem('ftc_tscale')||100;
document.documentElement.style.setProperty('--scale',(tscale.value/100));
tscale.oninput=e=>{ document.documentElement.style.setProperty('--scale',(e.target.value/100)); localStorage.setItem('ftc_tscale',e.target.value); };
const imgonlyEl=document.getElementById('imgonly');
imgonlyEl.checked = localStorage.getItem('ftc_imgonly')==='1';
document.body.classList.toggle('imgonly',imgonlyEl.checked);
imgonlyEl.onchange=e=>{ document.body.classList.toggle('imgonly',e.target.checked); localStorage.setItem('ftc_imgonly',e.target.checked?'1':'0'); };
document.getElementById('railToggle').onclick=()=>document.getElementById('rail').classList.toggle('open');

load();
</script></body></html>