← back to Dw Vendor Microsites

vendors/manuel_canovas/viewer/public/index.html

415 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">&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>
// 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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">&times;</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">&times;</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…';
  // Shopify checkout path (real sample order) when wired; else no-payment request below.
  if(window.DWV_SAMPLE && window.DWV_SAMPLE.available()){
    try{ btn.textContent='Opening secure checkout…'; await window.DWV_SAMPLE.checkout(cart,{name:payload.name,email:payload.email}); return; }
    catch(err){ /* fall through to the no-payment request flow */ }
  }
  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();
    }
    window.DWV_CFG = cfg.shopify || {};
    if(window.DWV_CFG.storefrontToken && window.DWV_CFG.sampleVariantId){
      await new Promise(res=>{const s=document.createElement('script');s.src='/buybutton.js';s.onload=res;s.onerror=res;document.head.appendChild(s);});
    }
  }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>