← 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 &amp; 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 &amp; 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">&times;</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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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">&times;</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>