← back to Cowtan Lines
viewer/public/index.html
328 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cowtan & Co. — 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}
/* 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{display:flex;align-items:center;gap:7px;padding:2px 0;font-size:13.5px;cursor:pointer}
.facet input{margin:0}
.facet .fc{margin-left:auto;color:var(--muted);font-size:11px}
.facet-search{width:100%;margin-bottom:6px;padding:5px 8px;font-size:13px}
.facet-scroll{max-height:210px;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:18px}
.card{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 .body{padding:10px 11px 12px;display:flex;flex-direction:column;gap:3px;flex:1}
.ptitle{font-weight:600;font-size:14px;line-height:1.25}
.meta{font-size:12px;color:var(--muted);display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.dot{width:13px;height:13px;border-radius:50%;border:1px solid rgba(0,0,0,.15);flex:0 0 auto}
.badge{display:inline-block;font-size:10px;letter-spacing:.06em;text-transform:uppercase;background:#f0ece4;color:var(--accent);border-radius:20px;padding:2px 8px;align-self:flex-start}
.coll{font-size:11.5px;color:var(--muted)}
.reqbtn{margin-top:auto;border:1px solid var(--ink);background:#fff;color:var(--ink);border-radius:7px;padding:7px;font-size:12.5px;font-weight:600;cursor:pointer}
.reqbtn.in{background:var(--accent2);color:#fff;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">Cowtan & Co.<small>Trade Lines</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="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>
<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>
const LS = {
get:(k,d)=>{try{const v=localStorage.getItem(k);return v==null?d:JSON.parse(v);}catch{return d;}},
set:(k,v)=>{try{localStorage.setItem(k,JSON.stringify(v));}catch{}}
};
const $ = (s)=>document.querySelector(s);
const grid=$('#grid'), statusEl=$('#status'), countEl=$('#count');
// ---- persisted UI state ----
let sort = LS.get('cowtan.sort','newest');
let density = LS.get('cowtan.density',5);
let cart = LS.get('cowtan.cart',[]); // array of item objects
let active = LS.get('cowtan.filters',{}); // {line:[...], product_type:[...], ...}
$('#sort').value=sort;
$('#density').value=density;
document.documentElement.style.setProperty('--cols',density);
// ---- paging / fetch ----
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=''; 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 card(row){
const el=document.createElement('div'); el.className='card';
const img = row.image ? `<img loading="lazy" src="${row.image}" alt="${esc(row.pattern_name)}" onerror="this.style.opacity=.25">` : '';
const dot = row.color_hex ? `<span class="dot" style="background:${row.color_hex}" title="${row.color_hex}"></span>` : '';
const lineBadge = row.line ? `<span class="badge">${esc(row.line)}</span>` : '';
el.innerHTML = `
<div class="imgwrap">${img}</div>
<div class="body">
${lineBadge}
<div class="ptitle">${esc(row.pattern_name)}</div>
<div class="meta">${dot}<span>${esc(row.color_name||'—')}</span></div>
${row.collection?`<div class="coll">${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>`;
el.querySelector('.reqbtn').addEventListener('click',()=>toggleCart(row, el.querySelector('.reqbtn')));
return el;
}
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
// ---- 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('cowtan.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;
}
$('#cartn').textContent=cart.length;
// ---- facets ----
async function loadFacets(){
const r=await fetch('/api/facets'); const d=await r.json();
const f=d.facets;
const meta=[
['line','Line',f.line,false],
['product_type','Product Type',f.product_type,false],
['color_primary','Primary Color',f.color_primary,false],
['repeat_classification','Repeat',f.repeat_classification,false],
['material','Material',f.material,false],
['collection','Collection',f.collection,true],
];
const root=$('#facets'); root.innerHTML='';
for(const [key,title,vals,searchable] of meta){
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='facet-scroll';
for(const {value,count} of vals){
const lab=document.createElement('label'); lab.className='facet'; lab.dataset.v=value.toLowerCase();
const cb=document.createElement('input'); cb.type='checkbox'; cb.value=value;
cb.checked = (active[key]||[]).includes(value);
cb.addEventListener('change',()=>{
active[key]=active[key]||[];
if(cb.checked) active[key].push(value); else active[key]=active[key].filter(v=>v!==value);
LS.set('cowtan.filters',active); reset();
});
lab.appendChild(cb);
const span=document.createElement('span'); span.textContent=value; lab.appendChild(span);
const c=document.createElement('span'); c.className='fc'; c.textContent=count; lab.appendChild(c);
box.appendChild(lab);
}
root.appendChild(box);
if(search) search.addEventListener('input',()=>{const t=search.value.toLowerCase();box.querySelectorAll('.facet').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('cowtan.filters',active);loadFacets();reset();});
root.appendChild(clear);
}
// ---- controls ----
$('#sort').addEventListener('change',e=>{sort=e.target.value;LS.set('cowtan.sort',sort);reset();});
$('#density').addEventListener('input',e=>{density=+e.target.value;LS.set('cowtan.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('cowtan.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('cowtan.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 ----
loadFacets();
load();
</script>
</body>
</html>