← back to Reidwitlin Landing
public/index.html
488 lines
<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Reid Witlin · Catalog · dw_unified</title>
<style>
:root{--cols:6;--fs:1;--bg:#0c0d12;--panel:#15171f;--card:#12141b;--ink:#eef0f5;--mut:#9aa0b0;--acc:#5eead4;--line:#262a36;
--new:#f59e0b;--pub:#34d399;--unpub:#94a3b8;--arch:#f87171;--ship:#60a5fa}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:13px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
header{position:sticky;top:0;z-index:9;background:linear-gradient(180deg,#0c0d12,#0c0d12e8);backdrop-filter:blur(8px);
border-bottom:1px solid var(--line);padding:10px 16px;display:flex;gap:12px;align-items:center;flex-wrap:wrap}
h1{font-size:15px;margin:0;font-weight:700;letter-spacing:.2px}
.pill{background:var(--new);color:#1a1205;font-weight:700;border-radius:999px;padding:2px 10px;font-size:12px}
.muted{color:var(--mut)}
select,input[type=text]{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:6px 9px;font-size:13px}
.ctl{display:flex;align-items:center;gap:7px}
input[type=range]{accent-color:var(--acc)}
.btn{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:6px 12px;cursor:pointer;font-size:13px;text-decoration:none}
.btn:hover{border-color:var(--acc)}
/* ── left panel: filters + field tables, ALL collapsed on load ── */
#layout{display:flex;align-items:flex-start}
aside{position:sticky;width:230px;min-width:230px;max-height:calc(100vh - 52px);overflow:auto;
padding:10px 10px 28px;border-right:1px solid var(--line);background:#0e0f15}
aside input[type=text]{width:100%;margin-bottom:7px;font-size:12.5px;padding:5px 8px}
aside details{border-bottom:1px solid var(--line)}
aside summary{cursor:pointer;list-style:none;display:flex;align-items:center;gap:6px;padding:8px 4px;
font-size:10.5px;font-weight:700;color:var(--mut);text-transform:uppercase;letter-spacing:.6px;user-select:none}
aside summary::-webkit-details-marker{display:none}
aside summary::before{content:'▸';font-size:9px;color:var(--mut);transition:transform .12s}
aside details[open] summary::before{transform:rotate(90deg)}
aside summary:hover{color:var(--ink)}
aside summary .cur{margin-left:auto;color:var(--acc);text-transform:none;letter-spacing:0;font-weight:600;
max-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fbody{padding:2px 0 8px}
.frow{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:3px 8px;border-radius:6px;
cursor:pointer;color:var(--mut);font-size:12.5px;user-select:none}
.frow:hover{background:var(--panel);color:var(--ink)}
.frow.active{background:var(--panel);color:var(--ink);outline:1px solid var(--acc);outline-offset:-1px}
.frow .fl{display:inline-flex;align-items:center;gap:7px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.frow .n{font-variant-numeric:tabular-nums;font-size:11px;color:var(--mut);flex:none}
.frow .dot{width:10px;height:10px;border-radius:50%;border:1px solid #0008;display:inline-block;flex:none}
.frow input[type=checkbox]{accent-color:var(--acc);margin:0 2px 0 0}
.fmore{padding:3px 8px;font-size:11px;color:var(--mut);font-style:italic}
#clearf{display:none;margin-top:10px;width:100%}
main{flex:1;min-width:0}
/* mobile: hamburger + full-screen filter drawer (sidebar no longer stacks above the grid) */
#burger{display:none;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:6px 12px;cursor:pointer;font-size:13px;font-weight:600;white-space:nowrap}
#fclose{display:none}
@media(max-width:880px){
#layout{display:block}
header{padding:8px 10px;gap:8px}
#burger{display:inline-flex;align-items:center;gap:6px}
.ctl:has(#density){display:none} /* grid is fixed 2-up on phones — slider is noise */
aside{display:none;position:fixed;inset:0;top:0!important;z-index:40;width:auto;min-width:0;max-height:none!important;
overflow:auto;border-right:0;padding:12px 12px 40px;background:var(--bg)}
aside.open-mobile{display:block}
#fclose{display:block;position:sticky;top:0;z-index:2;width:100%;background:var(--acc);color:#062a24;border:none;border-radius:8px;padding:11px;font-size:14px;font-weight:700;cursor:pointer;margin-bottom:10px}
.grid:not(.list){grid-template-columns:repeat(2,1fr)}
}
/* ── grid — tight, 1pt stroke edges; text scales with density via --fs ── */
.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:8px;padding:10px 12px 60px}
.card{background:var(--card);border:1px solid var(--line);border-radius:2px;overflow:hidden;display:flex;flex-direction:column;position:relative;cursor:pointer}
.card:hover{border-color:#414a5e}
.imgwrap{position:relative;width:100%;aspect-ratio:1/1;background:#0a0b0f}
.imgwrap img{width:100%;height:100%;object-fit:cover;object-position:center;display:block}
.imgwrap img.roomimg{position:absolute;inset:0;opacity:0;transition:opacity .18s ease}
.imgwrap:hover img.roomimg{opacity:1}
.roombadge{position:absolute;top:6px;right:6px;z-index:2;background:#000a;color:#cbd5e1;font-size:9px;font-weight:700;border-radius:4px;padding:1px 6px;letter-spacing:.3px}
.imgwrap.noimg{background:repeating-linear-gradient(45deg,#0a0b0f,#0a0b0f 8px,#101218 8px,#101218 16px)}
.imgwrap.noimg::after{content:'🚫 no image';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#5b6373;font-size:11px;font-weight:600;letter-spacing:.3px}
.card .b{padding:calc(6px*var(--fs)) calc(8px*var(--fs)) calc(7px*var(--fs));display:flex;flex-direction:column;gap:calc(3px*var(--fs))}
.vend{font-size:calc(10px*var(--fs));color:var(--mut);font-weight:600;letter-spacing:.2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ttl{font-weight:600;font-size:calc(12.5px*var(--fs));line-height:1.3;letter-spacing:-.1px}
.sku{font-family:ui-monospace,Menlo,monospace;font-size:calc(11px*var(--fs));color:var(--mut)}
/* field visibility toggles (grid view only — list view columns stay intact) */
body.f-off-vend .grid:not(.list) .vend{display:none}
body.f-off-ttl .grid:not(.list) .ttl{display:none}
body.f-off-chips .grid:not(.list) .chips{display:none}
body.imgonly .grid:not(.list) .card .b{display:none}
/* ── chips (minimal) ── */
.chips{display:flex;flex-wrap:wrap;gap:5px;align-items:center}
.chip{font-size:calc(10px*var(--fs));font-weight:600;border-radius:999px;padding:1px calc(8px*var(--fs));letter-spacing:.2px;white-space:nowrap;border:1px solid transparent}
.chip.cl{border-color:transparent;color:var(--mut);background:transparent;display:inline-flex;align-items:center;gap:5px;padding-left:0}
.chip.cl .dot{width:calc(9px*var(--fs));height:calc(9px*var(--fs));border-radius:50%;border:1px solid #0008;display:inline-block}
.chip.toggle{background:transparent;color:var(--mut);border-color:var(--line);cursor:pointer;user-select:none}
.chip.toggle:hover{border-color:var(--acc);color:var(--ink)}
/* ── collapsed detail drawer (direct child of .card so list-mode span works) ── */
.info{display:none;flex-direction:column;gap:7px;border-top:1px dashed var(--line);padding:8px 8px 9px;cursor:default}
.card.info-open .info{display:flex}
.kv{display:grid;grid-template-columns:88px 1fr;gap:4px 10px;font-size:11.5px;align-items:baseline}
.kv .k{color:var(--mut)}
.kv .v{text-align:left;word-break:break-word;font-weight:500}
.lnk{color:var(--acc);text-decoration:none;border:1px solid var(--line);border-radius:6px;padding:2px 7px;font-size:11px}
.lnk:hover{background:var(--panel)}
.acts{display:flex;flex-wrap:wrap;gap:6px;margin-top:3px}
.act{font-size:11px;font-weight:600;border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:7px;padding:4px 10px;cursor:pointer}
.act:hover{border-color:var(--acc)}
.act:disabled{opacity:.5;cursor:default}
body.imgonly .grid:not(.list) .card .acts{display:none}
#toast{position:fixed;left:50%;bottom:18px;transform:translateX(-50%) translateY(20px);max-width:min(560px,92vw);
background:var(--panel);color:var(--ink);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:120}
#toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
#toast.ok{border-color:#2a6a4a} #toast.err{border-color:#7a2a2a}
#more{display:block;margin:8px auto 60px;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:9px 18px;cursor:pointer}
#empty{padding:60px;text-align:center;color:var(--mut)}
/* ── List view ── aligned CSS-grid rows (.b uses display:contents so its
children become columns of the card grid). Fixed type sizes (1 column). */
:root{--listcols:42px 110px minmax(120px,1.2fr) minmax(90px,1fr) 92px minmax(110px,1fr) 112px minmax(110px,1.2fr) 64px 110px 92px}
.grid.list{grid-template-columns:1fr;gap:4px;padding:4px 12px 60px}
.grid.list .card{
display:grid;align-items:center;border-radius:2px;
grid-template-columns:var(--listcols);
column-gap:12px;min-height:52px;padding:0 12px 0 8px;
}
.grid.list .card>.b{display:contents}
.grid.list .imgwrap{width:42px;min-width:42px;height:42px;aspect-ratio:auto;border-radius:3px;overflow:hidden;margin:5px 0}
.grid.list .imgwrap.noimg::after{font-size:8px}
.grid.list .roombadge{display:none}
.grid.list .vend{font-size:10.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.grid.list .ttl{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:12.5px}
.grid.list .chips{flex-wrap:nowrap;gap:6px;overflow:hidden}
.grid.list .chip{font-size:10px;padding:1px 8px}
.grid.list .chips .chip.toggle{display:none}
.grid.list .lc{display:block;font-size:11px;color:var(--mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.grid.list .lsku,.grid.list .ldw{display:block;font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.grid.list .info{grid-column:1 / -1;margin:2px 0 6px;padding:8px 4px 2px}
.lsku,.ldw,.lc{display:none}
/* sortable column header — same template as rows, sticky under the main header */
#listhead{display:none;position:sticky;z-index:8;background:#0e0f15;border-bottom:1px solid var(--line);
grid-template-columns:var(--listcols);column-gap:12px;padding:6px 24px 6px 20px;align-items:center}
body.listmode #listhead{display:grid}
#listhead .lh{font-size:10.5px;font-weight:700;color:var(--mut);text-transform:uppercase;letter-spacing:.4px;
cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;user-select:none}
#listhead .lh:hover{color:var(--ink)}
#listhead .lh.on{color:var(--acc)}
/* ── memo-sample / inquiry modal (dark theme; kept from the vendor-landing build) ── */
.modal-back{position:fixed;inset:0;z-index:60;background:#000a;display:none;align-items:center;justify-content:center;padding:16px}
.modal-back.on{display:flex}
.modal{position:relative;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:22px;width:min(440px,100%);max-height:92vh;overflow:auto}
.modal h3{margin:2px 0 4px;font-size:16px}
.modal .msub{color:var(--mut);font-size:12px;margin-bottom:10px}
.modal label{display:block;font-size:10.5px;font-weight:700;color:var(--mut);text-transform:uppercase;letter-spacing:.5px;margin:10px 0 4px}
.modal input,.modal textarea{width:100%;background:var(--bg);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:8px 10px;font-size:13px;font-family:inherit}
.modal textarea{min-height:64px;resize:vertical}
.mclose{position:absolute;top:8px;right:10px;background:none;border:none;color:var(--mut);font-size:20px;cursor:pointer}
.mclose:hover{color:var(--ink)}
.modal .cta{margin-top:14px;width:100%;background:var(--acc);color:#062a24;border:none;border-radius:8px;padding:10px;font-size:13px;font-weight:700;cursor:pointer}
.modal .msg{font-size:12px;margin-top:8px;min-height:14px}
.modal .msg.ok{color:var(--pub)}
.modal .msg.err{color:var(--arch)}
</style></head>
<body>
<header>
<h1>Reid Witlin · Catalog</h1>
<button id="burger" type="button" aria-label="Open filters">☰ Filters</button>
<span class="pill" id="total">…</span>
<span class="muted" id="meta"></span>
<div class="ctl"><label class="muted">Sort</label>
<select id="sort">
<option value="newest">Newest</option>
<option value="color">Color</option>
<option value="style">Style</option>
<option value="pattern">Pattern A→Z</option>
<option value="sku">SKU A→Z</option>
<option value="title">Title A→Z</option>
<option value="book">Book</option>
</select></div>
<div class="ctl"><label class="muted">Density</label>
<input type="range" id="density" min="3" max="20" step="1"></div>
<button class="btn" id="view">▤ List view</button>
</header>
<div id="layout">
<aside aria-label="filters">
<button id="fclose" type="button">✕ Done — show products</button>
<input type="text" id="q" placeholder="Search pattern / sku / color…">
<input type="text" id="pattern" list="patternlist" placeholder="Pattern type-ahead…" title="type-ahead over every pattern in the catalog">
<datalist id="patternlist"></datalist>
<details id="fieldsbox"><summary>Card fields</summary>
<div class="fbody">
<label class="frow"><span class="fl"><input type="checkbox" data-fv="vend" checked>Book · series</span></label>
<label class="frow"><span class="fl"><input type="checkbox" data-fv="ttl" checked>Title / color</span></label>
<label class="frow"><span class="fl"><input type="checkbox" data-fv="chips" checked>Chips · details</span></label>
</div>
</details>
<div id="facetbox"><!-- one collapsed <details> table per data field --></div>
<button class="btn" id="clearf">✕ Clear filters</button>
</aside>
<main>
<div id="listhead" aria-label="sortable columns"></div>
<div class="grid" id="grid"></div>
<button id="more" style="display:none">Load more</button>
<div id="empty" style="display:none">No products match.</div>
</main>
</div>
<!-- MEMO-SAMPLE / INQUIRY MODAL (kept from the vendor-landing build; PDP also uses __openInquiry) -->
<div class="modal-back" id="inqBack">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="inqTitle">
<button class="mclose" id="inqClose" aria-label="Close">×</button>
<h3 id="inqTitle">Request a Memo Sample</h3>
<div class="msub" id="inqSub">A Designer Wallcoverings specialist will follow up with pricing and availability.</div>
<input type="hidden" id="inqSku">
<label for="inqName">Name</label>
<input id="inqName" autocomplete="name">
<label for="inqEmail">Email</label>
<input id="inqEmail" type="email" autocomplete="email" required>
<label for="inqNote">Note (optional)</label>
<textarea id="inqNote" placeholder="Project, quantity, timeline…"></textarea>
<div class="msg" id="inqMsg"></div>
<button class="cta" id="inqSend">Request Memo Sample</button>
</div>
</div>
<script>
const $=s=>document.querySelector(s);
const grid=$('#grid');
let ALL=[], FACETS=null, VIEW=[], shown=0;
const PAGE=120;
const BUCKET_HEX={white:'#f4f4f2',grey:'#9aa0aa',black:'#1c1e24',pink:'#f3a8c0',red:'#d0453e',orange:'#e08a3c',
brown:'#8a6242',gold:'#c9a75a',green:'#5f8f5c',teal:'#3f8a86',blue:'#4f7fb5',purple:'#8a6fb5'};
function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
// ── every data field this catalog ACTUALLY carries is a left-panel table ──
// (material / length / match are 0/996 in rwltd_catalog — skipped, not hidden-empty)
const FIELDS=[
['book','Books'],['style','Styles'],['color_bucket','Colors'],['series','Patterns'],['color','Colorways'],
['width','Width'],['repeat','Repeat'],
];
// style is derived from tags (interior-style lexicon) at load time
const STYLE_VOCAB=['Contemporary','Traditional','Transitional','Modern','Minimalist','Scandinavian','Rustic',
'Industrial','Coastal','Classic','Eclectic','Farmhouse','Bohemian','Organic Modern'];
const FIL={}; // field -> active value ('' = off)
FIELDS.forEach(([k])=>FIL[k]='');
let FCOUNTS={}; // field -> [[value,count]…] computed once from ALL
const FCAP=40; // rows shown per table (top-by-count)
// persisted prefs (CLAUDE.md: sort + density persist in localStorage) — rwltd.* namespace
$('#sort').value=localStorage.getItem('rwltd.sort')||'newest';
// ── density also drives type scale: fewer/larger cards → bigger type, 20/row → smallest ──
function setDensity(v){
v=Number(v);
const fs=Math.max(.5,Math.min(1.2, v<=6 ? 1+(6-v)*.06 : 1-(v-6)*.042));
document.documentElement.style.setProperty('--cols',v);
document.documentElement.style.setProperty('--fs',fs.toFixed(3));
}
const dens=localStorage.getItem('rwltd.density')||'6';
$('#density').value=dens; setDensity(dens);
$('#density').oninput=()=>{setDensity($('#density').value);localStorage.setItem('rwltd.density',$('#density').value);};
// ── mobile: hamburger opens the filter sidebar as a full-screen drawer ──
const asideEl=document.querySelector('aside');
$('#burger').onclick=()=>{asideEl.classList.add('open-mobile');document.body.style.overflow='hidden';};
$('#fclose').onclick=()=>{asideEl.classList.remove('open-mobile');document.body.style.overflow='';};
window.matchMedia('(max-width:880px)').addEventListener('change',e=>{
if(!e.matches){asideEl.classList.remove('open-mobile');document.body.style.overflow='';}
});
// ── card-field visibility toggles (image-only when all off) ──
const FVIS=JSON.parse(localStorage.getItem('rwltd.fieldvis')||'{"vend":true,"ttl":true,"chips":true}');
function applyFieldVis(){
for(const k of ['vend','ttl','chips'])document.body.classList.toggle('f-off-'+k,!FVIS[k]);
document.body.classList.toggle('imgonly',!FVIS.vend&&!FVIS.ttl&&!FVIS.chips);
document.querySelectorAll('#fieldsbox input[data-fv]').forEach(cb=>cb.checked=!!FVIS[cb.dataset.fv]);
localStorage.setItem('rwltd.fieldvis',JSON.stringify(FVIS));
}
document.querySelectorAll('#fieldsbox input[data-fv]').forEach(cb=>{
cb.onchange=()=>{FVIS[cb.dataset.fv]=cb.checked;applyFieldVis();};
});
applyFieldVis();
// NOTE: no <a> wrapper around the card — nesting anchors splits the DOM (adoption
// agency) and litters the grid with empty <a> cells. Navigation is delegated click.
function card(p){
const title=esc(p.display_name||p.series||p.sku||'(untitled)')+(p.color&&p.color!==p.display_name?(', '+esc(p.color)):'');
const hasRoom=p.room&&p.room!==p.swatch;
const img=p.swatch
?`<div class="imgwrap"><img loading="lazy" src="${esc(p.swatch)}" alt="${title}" onerror="this.style.display='none';this.parentNode.classList.add('noimg')">`+
(hasRoom?`<img loading="lazy" class="roomimg" src="${esc(p.room)}" alt=""><span class="roombadge">ROOM</span>`:'')+`</div>`
:`<div class="imgwrap noimg"></div>`;
const kv=(k,v)=>v?`<div class="kv"><span class="k">${k}</span><span class="v">${esc(v)}</span></div>`:'';
const dot=BUCKET_HEX[p.color_bucket]||'#666';
return `<div class="card" data-href="/product/${encodeURIComponent(p.handle)}" data-handle="${esc(p.handle)}" data-sku="${esc(p.dw_sku||'')}" data-mfr="${esc(p.sku||'')}" data-title="${esc((p.series||p.display_name||'')+(p.color?(' — '+p.color):''))}">
${img}<div class="b">
<span class="vend">${esc(p.book||'Reid Witlin')}${p.series&&p.series!==p.display_name?(' · '+esc(p.series)):''}</span>
<div class="ttl">${title}</div>
<span class="lc" title="Color">${esc(p.color||'')}</span>
<div class="chips">
${p.color_bucket?`<span class="chip cl"><span class="dot" style="background:${dot}"></span>${esc(p.color_bucket)}</span>`:''}
<span class="chip toggle" onclick="event.stopPropagation();this.closest('.card').classList.toggle('info-open')">ⓘ details</span>
</div>
<span class="lsku" title="Vendor SKU">${esc(p.sku||'')}</span>
<span class="ldw" title="DW SKU">${esc(p.dw_sku||'')}</span>
<span class="lc" title="Pattern">${esc(p.series||'')}</span>
<span class="lc" title="Width">${esc(p.width||'')}</span>
<span class="lc" title="Repeat">${esc(p.repeat||'')}</span>
<span class="lc" title="Added">${fmtAdded(p.published_at)}</span>
</div>
<div class="info" onclick="event.stopPropagation()">
${kv('DW SKU',p.dw_sku)}${kv('Vendor SKU',p.sku)}${kv('Pattern',p.series)}${kv('Book',p.book)}
${kv('Style',p.style)}${kv('Colorway',p.color)}${kv('Width',p.width)}${kv('Repeat',p.repeat)}
<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>
${p.sku?`<button class="act" data-copy="${esc(p.sku)}" onclick="copyTxt(this)" title="copy vendor SKU">📋 SKU</button>`:''}
${p.dw_sku?`<button class="act" data-copy="${esc(p.dw_sku)}" onclick="copyTxt(this)" title="copy DW SKU">📋 DW</button>`:''}
<a class="lnk" href="/product/${encodeURIComponent(p.handle)}">open ↗</a>
</div>
</div>
</div>`;
}
function fmtAdded(d){
if(!d) return '';
const t=new Date(d); if(isNaN(t)) return '';
return t.toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'});
}
// delegated card navigation — anything not interactive opens the PDP
grid.addEventListener('click',e=>{
if(e.target.closest('.chip.toggle,.info,a,button'))return;
const c=e.target.closest('.card[data-href]');
if(c)location.href=c.dataset.href;
});
function copyTxt(btn){
const t=btn.dataset.copy||'';
navigator.clipboard.writeText(t).then(()=>{
const o=btn.textContent; btn.textContent='✓ copied';
setTimeout(()=>{btn.textContent=o;},1200);
});
}
window.copyTxt=copyTxt;
// ── Purchasing actions on each card: Memo / Stock / Price → /api/request ──
grid.addEventListener('click', async (e)=>{
const btn=e.target.closest('.act[data-act]'); if(!btn) return;
e.stopPropagation();
const c=btn.closest('.card'); const type=btn.dataset.act;
const payload={ type, dw_sku:c.dataset.sku, mfr_sku:c.dataset.mfr, title:c.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);
}
// color sort key — white/grey/black first (by value), then chromatic by hue
function colorKey(p){
const b=p.color_bucket, hue=p.hue??999;
if(b==='white')return [0, p.val??1];
if(b==='grey') return [1, p.val??.5];
if(b==='black')return [2, -(p.val??0)];
return [3, hue];
}
function applyFilters(){
const q=$('#q').value.trim().toLowerCase(), pat=$('#pattern').value.trim().toLowerCase();
VIEW=ALL.filter(p=>{
for(const [k] of FIELDS){ if(FIL[k]&&String(p[k]??'')!==FIL[k])return false; }
if(pat&&!String(p.series||p.display_name||'').toLowerCase().includes(pat))return false;
if(q&&![p.display_name,p.series,p.sku,p.dw_sku,p.color,p.book].some(v=>v&&String(v).toLowerCase().includes(q)))return false;
return true;
});
const s=$('#sort').value, cmp={
newest:(a,b)=>new Date(b.published_at||0)-new Date(a.published_at||0),
color:(a,b)=>{const kx=colorKey(a),ky=colorKey(b);return kx[0]-ky[0]||kx[1]-ky[1];},
style:(a,b)=>{const av=a.style||'',bv=b.style||'';return av.localeCompare(bv)||String(a.series||'').localeCompare(String(b.series||''));},
pattern:(a,b)=>String(a.series||a.display_name||'').localeCompare(String(b.series||b.display_name||'')),
sku:(a,b)=>String(a.sku||a.handle).localeCompare(String(b.sku||b.handle)),
title:(a,b)=>String(a.display_name||'').localeCompare(String(b.display_name||'')),
book:(a,b)=>String(a.book||'').localeCompare(String(b.book||''))||String(a.series||'').localeCompare(String(b.series||'')),
}[s];
if(COLSORT.key){
const k=COLSORT.key, dir=COLSORT.dir;
VIEW=[...VIEW].sort((a,b)=>{
const av=a[k], bv=b[k];
if(av==null||av==='')return 1; if(bv==null||bv==='')return -1; // empties last either way
if(k==='published_at')return dir*(new Date(av)-new Date(bv));
return dir*String(av).localeCompare(String(bv),undefined,{numeric:true});
});
}else if(cmp)VIEW=[...VIEW].sort(cmp);
shown=0; grid.innerHTML=''; renderMore();
$('#total').textContent=VIEW.length.toLocaleString()+' designs';
$('#empty').style.display=VIEW.length?'none':'block';
renderSide();
}
function renderMore(){
const slice=VIEW.slice(shown,shown+PAGE);
grid.insertAdjacentHTML('beforeend',slice.map(card).join(''));
shown+=slice.length;
$('#meta').textContent=`${shown.toLocaleString()} of ${VIEW.length.toLocaleString()} shown`;
$('#more').style.display=shown<VIEW.length?'block':'none';
}
function buildFacetCache(){
FCOUNTS={};
for(const [k] of FIELDS){
const m={};
for(const p of ALL){const v=p[k];if(v==null||v==='')continue;m[v]=(m[v]||0)+1;}
FCOUNTS[k]=Object.entries(m).sort((a,b)=>b[1]-a[1]);
}
// Pattern type-ahead over EVERY distinct pattern
const series=(FCOUNTS.series||[]).map(([v])=>v).sort((a,b)=>String(a).localeCompare(String(b),undefined,{numeric:true}));
$('#patternlist').innerHTML=series.map(s=>`<option value="${esc(s)}">`).join('');
}
// left panel — one collapsed <details> table per field; open state survives re-render
function renderSide(){
const openNow=new Set([...document.querySelectorAll('#facetbox details[open]')].map(d=>d.dataset.f));
$('#facetbox').innerHTML=FIELDS.map(([k,label])=>{
const rows=(FCOUNTS[k]||[]).slice(0,FCAP).map(([v,n])=>{
const dot=k==='color_bucket'?`<span class="dot" style="background:${BUCKET_HEX[v]||'#666'}"></span>`:'';
return `<div class="frow${FIL[k]===String(v)?' active':''}" onclick="pickF('${k}',this.dataset.v)" data-v="${esc(v)}">
<span class="fl">${dot}${esc(v)}</span><span class="n">${n.toLocaleString()}</span></div>`;
}).join('');
const more=(FCOUNTS[k]||[]).length>FCAP?`<div class="fmore">+ ${(FCOUNTS[k].length-FCAP).toLocaleString()} more — use search</div>`:'';
const cur=FIL[k]?`<span class="cur" title="${esc(FIL[k])}">${esc(FIL[k])}</span>`:'';
return `<details data-f="${k}"${openNow.has(k)?' open':''}><summary>${label}${cur}</summary><div class="fbody">${rows}${more}</div></details>`;
}).join('');
$('#clearf').style.display=(Object.values(FIL).some(Boolean)||$('#q').value||$('#pattern').value)?'block':'none';
}
function pickF(k,v){FIL[k]=(FIL[k]===v?'':v);applyFilters();}
window.pickF=pickF;
$('#clearf').onclick=()=>{FIELDS.forEach(([k])=>FIL[k]='');$('#q').value='';$('#pattern').value='';applyFilters();};
$('#sort').onchange=()=>{localStorage.setItem('rwltd.sort',$('#sort').value);COLSORT.key=null;renderListHead();applyFilters();};
let t; $('#q').oninput=()=>{clearTimeout(t);t=setTimeout(applyFilters,300);};
let tp; $('#pattern').oninput=()=>{clearTimeout(tp);tp=setTimeout(applyFilters,300);};
$('#more').onclick=renderMore;
// ── sortable column header (list view) — order mirrors the card's .b children ──
const LIST_COLS=[[null,''],['book','Book'],['display_name','Name'],['color','Color'],['color_bucket','Bucket'],
['sku','SKU'],['dw_sku','DW SKU'],['series','Pattern'],['width','Width'],['repeat','Repeat'],['published_at','Added']];
const COLSORT={key:null,dir:1};
function renderListHead(){
$('#listhead').innerHTML=LIST_COLS.map(([k,lbl])=>k
?`<span class="lh${COLSORT.key===k?' on':''}" onclick="colSort('${k}')">${lbl}${COLSORT.key===k?(COLSORT.dir>0?' ▲':' ▼'):''}</span>`
:'<span></span>').join('');
syncSticky();
}
function colSort(k){
if(COLSORT.key===k)COLSORT.dir=-COLSORT.dir; else{COLSORT.key=k;COLSORT.dir=1;}
renderListHead(); applyFilters();
}
window.colSort=colSort;
function syncSticky(){
const h=document.querySelector('header').offsetHeight;
$('#listhead').style.top=h+'px';
document.querySelector('aside').style.top=h+'px';
document.querySelector('aside').style.maxHeight=`calc(100vh - ${h}px)`;
}
addEventListener('resize',syncSticky);
function applyView(){const list=localStorage.getItem('rwltd.view')==='list';grid.classList.toggle('list',list);document.body.classList.toggle('listmode',list);$('#view').textContent=list?'▦ Grid view':'▤ List view';renderListHead();}
$('#view').onclick=()=>{localStorage.setItem('rwltd.view',localStorage.getItem('rwltd.view')==='list'?'grid':'list');applyView();};
applyView();
addEventListener('scroll',()=>{if(shown<VIEW.length&&innerHeight+scrollY>=document.body.offsetHeight-600)renderMore();});
// ── memo-sample modal wiring (kept feature; PDP calls __openInquiry too) ──
function openInquiry(sku,label){
$('#inqSku').value=sku||'';
$('#inqSub').textContent = sku
? `Requesting a memo sample of ${label||sku}. A specialist will follow up with pricing and availability.`
: 'A Designer Wallcoverings specialist will follow up with pricing and availability.';
$('#inqMsg').textContent=''; $('#inqMsg').className='msg';
$('#inqBack').classList.add('on');
}
window.__openInquiry=openInquiry;
$('#inqClose').onclick=()=>$('#inqBack').classList.remove('on');
$('#inqBack').addEventListener('click',e=>{if(e.target.id==='inqBack')$('#inqBack').classList.remove('on');});
$('#inqSend').onclick=async()=>{
const email=$('#inqEmail').value.trim();
const m=$('#inqMsg');
if(!/.+@.+\..+/.test(email)){m.textContent='Please enter a valid email.';m.className='msg err';return;}
$('#inqSend').disabled=true;
try{
const r=await fetch(location.origin+'/api/inquiry',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({sku:$('#inqSku').value||'reidwitlin-collection',name:$('#inqName').value.trim(),email,note:$('#inqNote').value.trim()})});
const j=await r.json();
if(j.ok){m.textContent=j.message;m.className='msg ok';setTimeout(()=>$('#inqBack').classList.remove('on'),1800);}
else{m.textContent=j.error||'Something went wrong.';m.className='msg err';}
}catch(e){m.textContent='Network error — please email info@designerwallcoverings.com.';m.className='msg err';}
$('#inqSend').disabled=false;
};
async function loadAll(){
const [pd]=await Promise.all([
fetch(location.origin+'/api/products').then(r=>r.json())]);
ALL=pd.products;
// derive style from tags (interior-style lexicon; rwltd_catalog has no style column)
for(const p of ALL){ p.style=(p.tags||[]).find(t=>STYLE_VOCAB.includes(t))||null; }
buildFacetCache(); applyFilters();
}
loadAll();
</script>
</body></html>