← back to Dw Vendor Microsites
vendors/cowtan_tout/viewer/public/index.html
406 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>Trade Lines</title>
<style>
:root{
--cols: 5; /* density-driven grid columns */
--ink:#1c1a17; --muted:#6f6a62; --line:#e7e2da; --bg:#faf8f4; --card:#fff;
--accent:#7a6a52; --accent2:#3c5a4a;
}
*{box-sizing:border-box}
html,body{margin:0}
body{font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;color:var(--ink);background:var(--bg)}
a{color:inherit}
header.top{padding:22px 24px 12px;border-bottom:1px solid var(--line);background:#fff;position:sticky;top:0;z-index:30}
.brand{font-size:24px;letter-spacing:.04em;font-weight:600}
.brand small{display:block;font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);font-weight:500;margin-top:3px}
.count{color:var(--muted);font-size:13px;margin-left:auto}
.toprow{display:flex;align-items:flex-end;gap:18px;flex-wrap:wrap}
/* controls bar */
.controls{display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:12px 24px;border-bottom:1px solid var(--line);background:#fff;position:sticky;top:73px;z-index:20}
.controls label{font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);display:flex;flex-direction:column;gap:4px}
select,input[type=search],input[type=text],input[type=email],textarea{font:inherit;padding:7px 9px;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink)}
input[type=range]{width:120px}
.grow{flex:1;min-width:180px}
.density{display:flex;flex-direction:column;gap:4px}
.cartbtn{margin-left:auto;background:var(--ink);color:#fff;border:none;border-radius:8px;padding:9px 16px;font-weight:600;cursor:pointer;letter-spacing:.02em}
.cartbtn .n{display:inline-block;background:var(--accent2);border-radius:20px;padding:1px 8px;margin-left:6px;font-size:12px}
/* active-filter chips bar */
.activebar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:10px 24px;border-bottom:1px solid var(--line);background:#fcfbf8}
.activebar.empty{display:none}
.achip{display:inline-flex;align-items:center;gap:6px;background:var(--ink);color:#fff;border-radius:20px;padding:4px 6px 4px 11px;font-size:12.5px;line-height:1}
.achip .k{opacity:.6;text-transform:uppercase;letter-spacing:.06em;font-size:10px}
.achip .x{border:none;background:rgba(255,255,255,.2);color:#fff;border-radius:50%;width:18px;height:18px;cursor:pointer;font-size:13px;line-height:1;padding:0}
.clearall{margin-left:auto;border:1px solid var(--line);background:#fff;border-radius:7px;padding:6px 12px;cursor:pointer;font-size:12.5px}
/* layout */
.wrap{display:flex;align-items:flex-start}
aside{width:248px;flex:0 0 248px;padding:18px 16px 60px;border-right:1px solid var(--line);position:sticky;top:135px;max-height:calc(100vh - 135px);overflow:auto}
aside h3{font-size:11px;text-transform:uppercase;letter-spacing:.12em;color:var(--muted);margin:18px 0 8px}
aside h3:first-child{margin-top:0}
/* facet CHIPS (toggle, multi-select) */
.chips{display:flex;flex-wrap:wrap;gap:6px}
.chip{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:#fff;border-radius:18px;padding:4px 10px;font-size:12.5px;cursor:pointer;user-select:none}
.chip .cc{color:var(--muted);font-size:10.5px}
.chip.on{background:var(--ink);color:#fff;border-color:var(--ink)}
.chip.on .cc{color:rgba(255,255,255,.7)}
.chip.zero{opacity:.38;cursor:default;pointer-events:none}
.chip .swatch{width:11px;height:11px;border-radius:50%;border:1px solid rgba(0,0,0,.2);flex:0 0 auto}
.facet-search{width:100%;margin-bottom:8px;padding:5px 8px;font-size:13px}
.facet-scroll{max-height:230px;overflow:auto;padding-right:4px}
.clearbtn{margin-top:14px;width:100%;padding:8px;border:1px solid var(--line);background:#fff;border-radius:7px;cursor:pointer;font-size:13px}
main{flex:1;padding:18px 24px 80px;min-width:0}
.grid{display:grid;grid-template-columns:repeat(var(--cols),minmax(0,1fr));gap:14px}
/* MINIMAL image-forward cards: image + hex dot(s) only; detail on hover/focus */
.card{position:relative;background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden;display:flex;flex-direction:column}
.card .imgwrap{aspect-ratio:1/1;background:#f1ede6;overflow:hidden;position:relative}
.card img{width:100%;height:100%;object-fit:cover;display:block}
.card .dots{position:absolute;left:8px;bottom:8px;display:flex;gap:4px;z-index:2}
.card .dot{width:14px;height:14px;border-radius:50%;border:1px solid rgba(255,255,255,.85);box-shadow:0 0 0 1px rgba(0,0,0,.18)}
/* reveal overlay (hover or .open via click) */
.reveal{position:absolute;inset:auto 0 0 0;background:linear-gradient(to top,rgba(20,18,15,.92),rgba(20,18,15,.78) 60%,rgba(20,18,15,0));color:#fff;padding:30px 11px 11px;transform:translateY(101%);transition:transform .18s ease;display:flex;flex-direction:column;gap:3px}
.card:hover .reveal,.card.open .reveal,.card:focus-within .reveal{transform:translateY(0)}
.reveal .ptitle{font-weight:600;font-size:13.5px;line-height:1.25}
.reveal .rmeta{font-size:11.5px;color:rgba(255,255,255,.82)}
.reveal .badge{display:inline-block;font-size:9.5px;letter-spacing:.06em;text-transform:uppercase;background:rgba(255,255,255,.16);border-radius:20px;padding:2px 7px;align-self:flex-start;margin-bottom:1px}
.reqbtn{margin-top:6px;border:1px solid #fff;background:transparent;color:#fff;border-radius:7px;padding:6px;font-size:12px;font-weight:600;cursor:pointer}
.reqbtn.in{background:var(--accent2);border-color:var(--accent2)}
#sentinel{height:60px}
.status{text-align:center;color:var(--muted);padding:24px;font-size:13px}
/* modal */
.overlay{position:fixed;inset:0;background:rgba(28,26,23,.45);display:none;align-items:flex-start;justify-content:center;z-index:50;padding:40px 16px;overflow:auto}
.overlay.open{display:flex}
.modal{background:#fff;border-radius:12px;max-width:560px;width:100%;padding:22px 24px 26px}
.modal h2{margin:0 0 4px;font-size:20px}
.modal p.sub{margin:0 0 16px;color:var(--muted);font-size:13px}
.cartlist{max-height:230px;overflow:auto;border:1px solid var(--line);border-radius:8px;margin-bottom:16px}
.citem{display:flex;align-items:center;gap:10px;padding:8px 10px;border-bottom:1px solid var(--line);font-size:13px}
.citem:last-child{border-bottom:none}
.citem img{width:42px;height:42px;object-fit:cover;border-radius:6px;background:#eee}
.citem .x{margin-left:auto;border:none;background:none;color:var(--muted);cursor:pointer;font-size:18px}
.field{display:flex;flex-direction:column;gap:4px;margin-bottom:11px}
.field label{font-size:12px;color:var(--muted)}
.row2{display:flex;gap:11px}.row2 .field{flex:1}
.submit{width:100%;background:var(--ink);color:#fff;border:none;border-radius:8px;padding:12px;font-weight:600;font-size:15px;cursor:pointer;margin-top:4px}
.submit:disabled{opacity:.5;cursor:default}
.closex{float:right;border:none;background:none;font-size:22px;cursor:pointer;color:var(--muted);line-height:1}
.ok{background:#eef5ef;border:1px solid #cfe3d4;color:#2c5d3c;padding:12px;border-radius:8px;font-size:14px}
@media(max-width:820px){aside{display:none}.controls{top:auto;position:static}}
</style>
</head>
<body>
<header class="top">
<div class="toprow">
<div class="brand" id="brand">Trade Lines<small>Designer Wallcoverings</small></div>
<div class="count" id="count">loading…</div>
</div>
</header>
<div class="controls">
<label class="grow">Search
<input type="search" id="q" placeholder="pattern, color, SKU, collection…" autocomplete="off">
</label>
<label>Sort
<select id="sort">
<option value="newest">Newest</option>
<option value="family">Color Family</option>
<option value="style">Style</option>
<option value="pattern">Pattern A→Z</option>
<option value="color_name">Color name A→Z</option>
<option value="collection">Collection A→Z</option>
<option value="line">Line</option>
<option value="product_type">Product Type</option>
<option value="material">Material</option>
<option value="light">Light → Dark</option>
<option value="dark">Dark → Light</option>
<option value="hue">Color Wheel (hue)</option>
<option value="sku">SKU A→Z</option>
</select>
</label>
<label class="density">Density
<input type="range" id="density" min="2" max="8" step="1" value="5">
</label>
<button class="cartbtn" id="cartbtn">Request Samples<span class="n" id="cartn">0</span></button>
</div>
<!-- active-filter chips -->
<div class="activebar empty" id="activebar"></div>
<div class="wrap">
<aside id="facets"></aside>
<main>
<div class="grid" id="grid"></div>
<div id="sentinel"></div>
<div class="status" id="status"></div>
</main>
</div>
<!-- sample request modal -->
<div class="overlay" id="overlay">
<div class="modal">
<button class="closex" id="closex">×</button>
<h2>Request Samples</h2>
<p class="sub">No payment — we’ll mail trade samples of the items below.</p>
<div id="formArea">
<div class="cartlist" id="cartlist"></div>
<form id="reqform">
<div class="row2">
<div class="field"><label>Name *</label><input type="text" name="name" required></div>
<div class="field"><label>Email *</label><input type="email" name="email" required></div>
</div>
<div class="row2">
<div class="field"><label>Company / Studio</label><input type="text" name="company"></div>
<div class="field"><label>Phone</label><input type="text" name="phone"></div>
</div>
<div class="field"><label>Shipping address</label><textarea name="address" rows="2"></textarea></div>
<div class="field"><label>Notes</label><textarea name="notes" rows="2" placeholder="Optional"></textarea></div>
<button class="submit" id="submitbtn" type="submit">Submit sample request</button>
</form>
</div>
<div id="doneArea" style="display:none"></div>
</div>
</div>
<script>
// per-vendor localStorage namespace (set after /api/config so two microsites don't collide)
let NS = 'dwvm';
const LS = {
get:(k,d)=>{try{const v=localStorage.getItem(NS+'.'+k);return v==null?d:JSON.parse(v);}catch{return d;}},
set:(k,v)=>{try{localStorage.setItem(NS+'.'+k,JSON.stringify(v));}catch{}}
};
const $ = (s)=>document.querySelector(s);
const grid=$('#grid'), statusEl=$('#status'), countEl=$('#count');
// approximate swatch color per family (for the chip dot)
const FAMILY_SWATCH = {
'Red':'#c0392b','Orange/Brown':'#b5651d','Yellow':'#e1c340','Green':'#4a7c3f','Teal':'#2a9d8f',
'Blue':'#3a6ea5','Purple':'#7e57c2','Pink':'#e08aa8','Neutral/Beige':'#d8cbb3','Black':'#222',
'Grey':'#9a9a9a','White':'#f4f4f0'
};
// pretty labels for the active-chip key
const FACET_LABEL = {family:'Color',style:'Style',material_bucket:'Material',enriched:'Status',line:'Line',product_type:'Type',collection:'Collection'};
// ---- persisted UI state ----
let sort, density, cart, active;
let offset=0, total=0, loading=false, done=false;
const LIMIT=60;
function qs(){
const p=new URLSearchParams();
p.set('sort',sort); p.set('offset',offset); p.set('limit',LIMIT);
const q=$('#q').value.trim(); if(q) p.set('q',q);
for(const k of Object.keys(active)){ if(active[k] && active[k].length) p.set(k, active[k].join('|')); }
return p.toString();
}
function reset(){ offset=0; total=0; done=false; grid.innerHTML=''; statusEl.textContent=''; renderActive(); load(); }
async function load(){
if(loading||done) return;
loading=true; statusEl.textContent='loading…';
try{
const r=await fetch('/api/skus?'+qs());
const d=await r.json();
total=d.total;
for(const row of d.rows) grid.appendChild(card(row));
offset += d.rows.length;
if(offset>=total || d.rows.length===0) done=true;
countEl.textContent = total.toLocaleString()+' of '+d.all.toLocaleString()+' products';
statusEl.textContent = done ? (total? 'End of results — '+total.toLocaleString()+' shown' : 'No products match your filters.') : '';
}catch(e){ statusEl.textContent='Error loading products.'; }
loading=false;
}
function inCart(sku){ return cart.some(c=>c.sku===sku); }
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
// MINIMAL card: image + hex dot(s) only; everything else in hover/click reveal
function card(row){
const el=document.createElement('div'); el.className='card'; el.tabIndex=0;
const img = row.image ? `<img loading="lazy" src="${row.image}" alt="${esc(row.pattern_name)}" onerror="this.style.opacity=.25">` : '';
let dots='';
if(row.color_hex) dots += `<span class="dot" style="background:${row.color_hex}" title="${row.color_hex}"></span>`;
if(!row.color_hex && row.family) dots += `<span class="dot" style="background:${FAMILY_SWATCH[row.family]||'#bbb'}" title="${esc(row.family)}"></span>`;
const lineBadge = row.line ? `<span class="badge">${esc(row.line)}</span>` : '';
el.innerHTML = `
<div class="imgwrap">
${img}
<div class="dots">${dots}</div>
<div class="reveal">
${lineBadge}
<div class="ptitle">${esc(row.pattern_name)}</div>
<div class="rmeta">${esc(row.color_name||'—')}${row.family?' · '+esc(row.family):''}</div>
${row.collection?`<div class="rmeta">${esc(row.collection)}</div>`:''}
<button class="reqbtn ${inCart(row.sku)?'in':''}" data-sku="${esc(row.sku)}">${inCart(row.sku)?'✓ In sample request':'Request Sample'}</button>
</div>
</div>`;
// click anywhere on card (not the button) toggles the reveal open (touch/click reveal)
el.addEventListener('click',(e)=>{ if(e.target.closest('.reqbtn')) return; el.classList.toggle('open'); });
el.querySelector('.reqbtn').addEventListener('click',(e)=>{e.stopPropagation();toggleCart(row, e.currentTarget);});
return el;
}
// ---- cart ----
function toggleCart(row, btn){
if(inCart(row.sku)){ cart=cart.filter(c=>c.sku!==row.sku); }
else cart.push({sku:row.sku,mfr_sku:row.mfr_sku,pattern_name:row.pattern_name,color_name:row.color_name,line:row.line,image:row.image});
LS.set('cart',cart);
if(btn){ btn.classList.toggle('in',inCart(row.sku)); btn.textContent=inCart(row.sku)?'✓ In sample request':'Request Sample'; }
$('#cartn').textContent=cart.length;
}
// ---- active-filter chip bar (removable) ----
function renderActive(){
const bar=$('#activebar'); bar.innerHTML='';
let any=false;
for(const k of Object.keys(active)){
for(const v of (active[k]||[])){
any=true;
const c=document.createElement('span'); c.className='achip';
const label = k==='enriched' ? (v==='enriched'?'Enriched':'Unenriched') : v;
c.innerHTML=`<span class="k">${esc(FACET_LABEL[k]||k)}</span><span>${esc(label)}</span><button class="x" title="remove">×</button>`;
c.querySelector('.x').addEventListener('click',()=>{ active[k]=active[k].filter(x=>x!==v); if(!active[k].length) delete active[k]; LS.set('filters',active); loadFacets(); reset(); });
bar.appendChild(c);
}
}
if(any){
const clr=document.createElement('button'); clr.className='clearall'; clr.textContent='Clear all';
clr.addEventListener('click',()=>{active={};LS.set('filters',active);loadFacets();reset();});
bar.appendChild(clr);
bar.classList.remove('empty');
} else bar.classList.add('empty');
}
function toggleFacet(key,value){
active[key]=active[key]||[];
if(active[key].includes(value)) active[key]=active[key].filter(v=>v!==value);
else active[key].push(value);
if(!active[key].length) delete active[key];
LS.set('filters',active); loadFacets(); reset();
}
// ---- facets as CHIPS ----
async function loadFacets(){
const r=await fetch('/api/facets'); const d=await r.json();
const f=d.facets;
const meta=[
['family','Color Family',f.family,false,true],
['style','Style',f.style,false,false],
['material_bucket','Material',f.material_bucket,false,false],
['enriched','Enrichment',f.enriched,false,false],
['line','Line',f.line,false,false],
['product_type','Product Type',f.product_type,false,false],
['collection','Collection',f.collection,true,false],
];
const root=$('#facets'); root.innerHTML='';
for(const [key,title,vals,searchable,swatch] of meta){
if(!vals || !vals.length) continue;
const h=document.createElement('h3'); h.textContent=title; root.appendChild(h);
let search=null;
if(searchable){
search=document.createElement('input'); search.type='text'; search.className='facet-search'; search.placeholder='Filter '+title.toLowerCase()+'…';
root.appendChild(search);
}
const box=document.createElement('div'); box.className=searchable?'chips facet-scroll':'chips';
for(const {value,count} of vals){
const on=(active[key]||[]).includes(value);
const chip=document.createElement('span');
chip.className='chip'+(on?' on':'')+(count===0&&!on?' zero':'');
chip.dataset.v=value.toLowerCase();
const label = key==='enriched' ? (value==='enriched'?'Enriched':'Unenriched') : value;
const sw = swatch ? `<span class="swatch" style="background:${FAMILY_SWATCH[value]||'#bbb'}"></span>` : '';
chip.innerHTML=`${sw}<span>${esc(label)}</span><span class="cc">${count}</span>`;
if(!(count===0&&!on)) chip.addEventListener('click',()=>toggleFacet(key,value));
box.appendChild(chip);
}
root.appendChild(box);
if(search) search.addEventListener('input',()=>{const t=search.value.toLowerCase();box.querySelectorAll('.chip').forEach(l=>l.style.display=l.dataset.v.includes(t)?'':'none');});
}
const clear=document.createElement('button'); clear.className='clearbtn'; clear.textContent='Clear all filters';
clear.addEventListener('click',()=>{active={};LS.set('filters',active);loadFacets();reset();});
root.appendChild(clear);
}
// ---- controls ----
$('#sort').addEventListener('change',e=>{sort=e.target.value;LS.set('sort',sort);reset();});
$('#density').addEventListener('input',e=>{density=+e.target.value;LS.set('density',density);document.documentElement.style.setProperty('--cols',density);});
let qt; $('#q').addEventListener('input',()=>{clearTimeout(qt);qt=setTimeout(reset,250);});
// ---- infinite scroll ----
new IntersectionObserver((ents)=>{ if(ents[0].isIntersecting) load(); },{rootMargin:'600px'}).observe($('#sentinel'));
// ---- sample modal ----
const overlay=$('#overlay');
function openCart(){
renderCartList();
$('#formArea').style.display = cart.length? '' : 'none';
$('#doneArea').style.display='none'; $('#doneArea').innerHTML='';
overlay.classList.add('open');
}
function closeCart(){ overlay.classList.remove('open'); }
$('#cartbtn').addEventListener('click',openCart);
$('#closex').addEventListener('click',closeCart);
overlay.addEventListener('click',e=>{if(e.target===overlay)closeCart();});
function renderCartList(){
const list=$('#cartlist');
if(!cart.length){ list.innerHTML='<div class="status">No items yet. Click “Request Sample” on a product.</div>'; return; }
list.innerHTML='';
cart.forEach(it=>{
const row=document.createElement('div'); row.className='citem';
row.innerHTML=`${it.image?`<img src="${it.image}">`:''}
<div><strong>${esc(it.pattern_name||it.sku)}</strong><br><span style="color:#6f6a62">${esc(it.color_name||'')} ${it.line?'· '+esc(it.line):''}</span></div>
<button class="x" title="remove">×</button>`;
row.querySelector('.x').addEventListener('click',()=>{cart=cart.filter(c=>c.sku!==it.sku);LS.set('cart',cart);$('#cartn').textContent=cart.length;document.querySelectorAll('.reqbtn').forEach(b=>{if(b.dataset.sku===it.sku){b.classList.remove('in');b.textContent='Request Sample';}});openCart();});
list.appendChild(row);
});
}
$('#reqform').addEventListener('submit',async e=>{
e.preventDefault();
if(!cart.length) return;
const fd=new FormData(e.target);
const payload={items:cart};
for(const [k,v] of fd.entries()) payload[k]=v;
const btn=$('#submitbtn'); btn.disabled=true; btn.textContent='Submitting…';
try{
const r=await fetch('/api/sample-request',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
const d=await r.json();
if(d.ok){
cart=[]; LS.set('cart',cart); $('#cartn').textContent=0;
document.querySelectorAll('.reqbtn.in').forEach(b=>{b.classList.remove('in');b.textContent='Request Sample';});
$('#formArea').style.display='none';
$('#doneArea').style.display='';
$('#doneArea').innerHTML=`<div class="ok">Thank you, ${esc(payload.name)} — your request for ${d.count} sample${d.count===1?'':'s'} was received. We’ll be in touch at ${esc(payload.email)}.</div>`;
} else { btn.disabled=false; btn.textContent='Submit sample request'; alert(d.error||'Could not submit.'); }
}catch(err){ btn.disabled=false; btn.textContent='Submit sample request'; alert('Network error.'); }
});
// ---- boot: read /api/config for title + per-vendor namespace, then hydrate state ----
(async function boot(){
try{
const cfg=await (await fetch('/api/config')).json();
if(cfg.title){
document.title = cfg.title;
$('#brand').innerHTML = esc(cfg.title)+'<small>Designer Wallcoverings</small>';
NS = 'dwvm.'+cfg.title.replace(/[^a-z0-9]+/gi,'_').toLowerCase();
}
}catch{}
sort = LS.get('sort','newest');
density = LS.get('density',5);
cart = LS.get('cart',[]);
active = LS.get('filters',{});
$('#sort').value=sort;
$('#density').value=density;
document.documentElement.style.setProperty('--cols',density);
$('#cartn').textContent=cart.length;
renderActive();
loadFacets();
load();
})();
</script>
</body>
</html>