← back to All Designerwallcoverings
public/vendor-internal.html
72 lines
<!doctype html>
<html lang="en">
<head>
<script>
// Credential-safe fetch guard (fleet drop-in) — if this page is opened with
// credentials in the URL (a saved bookmark / Chrome-remembered basic-auth:
// https://user:pass@host/…), the browser poisons document.baseURI, so any
// relative or root-relative fetch('/api/…') throws "Request cannot be constructed
// from a URL that includes credentials" and callers silently fall to an empty
// state. Resolve every non-absolute request URL against the credential-free
// location instead of baseURI. Placed first so it wraps window.fetch before any
// app script runs. Ref: creds-in-url-fetch-guard-fleet-pattern.
(function () {
var _fetch = window.fetch.bind(window);
var cleanBase = function () { return location.origin + location.pathname; };
window.fetch = function (input, init) {
try {
if (typeof input === 'string' && !/^[a-z]+:\/\//i.test(input) && input.indexOf('//') !== 0) {
input = new URL(input, cleanBase()).href;
} else if (input instanceof Request && !/^[a-z]+:\/\//i.test(input.url)) {
input = new Request(new URL(input.url, cleanBase()).href, input);
}
} catch (_) { /* fall through to native */ }
return _fetch(input, init);
};
})();
</script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>Vendor Catalog · dw_unified</title>
<style>
:root{--cols:6;--bg:#0c0d12;--panel:#15171f;--card:#11131a;--ink:#eef0f5;--mut:#9aa0b0;--line:#292d38;--acc:#5eead4;--warn:#f59e0b;--good:#34d399;--bad:#f87171}
*{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:20;display:flex;align-items:center;gap:12px;flex-wrap:wrap;padding:10px 16px;background:#0c0d12ed;border-bottom:1px solid var(--line);backdrop-filter:blur(10px)}
h1{font-size:15px;margin:0}.pill,.chip{border-radius:999px;padding:2px 9px;font-size:11px;font-weight:700}.pill{background:var(--warn);color:#1a1205}.muted{color:var(--mut)}
button,select,input{font:inherit}button,select,input[type=search]{border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);padding:6px 9px}button{cursor:pointer}.control{display:flex;align-items:center;gap:6px}input[type=range]{accent-color:var(--acc)}
#layout{display:flex;align-items:flex-start}aside{position:sticky;top:53px;width:238px;min-width:238px;height:calc(100vh - 53px);overflow:auto;padding:10px;border-right:1px solid var(--line);background:#0e0f15}aside input{width:100%;margin-bottom:8px}
details{border-bottom:1px solid var(--line)}summary{cursor:pointer;padding:8px 3px;text-transform:uppercase;letter-spacing:.08em;font-size:10px;font-weight:700;color:var(--mut)}.facet{padding:0 0 8px}.frow{display:flex;justify-content:space-between;gap:8px;width:100%;border:0;background:transparent;padding:4px 7px;color:var(--mut);text-align:left}.frow:hover,.frow.on{background:var(--panel);color:var(--ink)}
main{min-width:0;flex:1}.grid{display:grid;grid-template-columns:repeat(var(--cols),minmax(0,1fr));gap:8px;padding:10px 12px 70px}.card{min-width:0;background:var(--card);border:1px solid var(--line);border-radius:3px;overflow:hidden}.card:hover{border-color:#566174}.pic{aspect-ratio:1;background:#090a0e;position:relative}.pic img{width:100%;height:100%;object-fit:cover}.pic .alt{position:absolute;inset:0;opacity:0;transition:.18s}.pic:hover .alt{opacity:1}.body{padding:7px 8px;display:grid;gap:4px}.title{font-weight:650;line-height:1.25}.sku{font:11px ui-monospace,Menlo,monospace;color:var(--mut);overflow:hidden;text-overflow:ellipsis}.chips{display:flex;gap:4px;flex-wrap:wrap}.chip{border:1px solid var(--line);color:var(--mut);font-size:9px;padding:1px 6px}.chip.active{color:#08251b;background:var(--good);border-color:var(--good)}.chip.staged{color:#211604;background:var(--warn);border-color:var(--warn)}.when{font-size:10px;color:var(--mut)}.actions{display:flex;gap:5px;flex-wrap:wrap}.actions a{font-size:10px;color:var(--acc);text-decoration:none;border:1px solid var(--line);border-radius:5px;padding:2px 6px}
#more,#empty{display:block;margin:10px auto 60px}#empty{text-align:center;color:var(--mut);padding:50px}.list{grid-template-columns:1fr}.list .card{display:grid;grid-template-columns:62px minmax(170px,2fr) minmax(110px,1fr) minmax(150px,1fr) 180px;align-items:center;padding-right:10px}.list .pic{width:52px;height:52px;margin:5px}.list .body{display:contents}.list .chips,.list .actions{white-space:nowrap;overflow:hidden}.list .when{padding-left:8px}
#filtersBtn,#closeFilters{display:none}@media(max-width:880px){#filtersBtn,#closeFilters{display:block}aside{display:none;position:fixed;inset:0;z-index:50;width:auto;height:auto;top:0}aside.open{display:block}.grid{grid-template-columns:repeat(2,minmax(0,1fr));padding:8px}.control.density{display:none}.list .card{display:block;padding:0}.list .pic{width:100%;height:auto;aspect-ratio:1;margin:0}.list .body{display:grid}.list .when{padding-left:0}}
</style>
</head>
<body>
<header>
<h1 id="brand">Vendor · Catalog</h1><button id="filtersBtn">☰ Filters</button><span class="pill" id="count">…</span><span class="muted" id="meta">dw_unified</span>
<label class="control">Sort <select id="sort"><option value="newest">Newest</option><option value="color">Color</option><option value="style">Style</option><option value="sku">SKU A→Z</option><option value="title">Title A→Z</option><option value="price_asc">Price ↑</option><option value="price_desc">Price ↓</option></select></label>
<label class="control density">Density <input id="density" type="range" min="3" max="12" value="6"></label><button id="view">▤ List view</button>
</header>
<div id="layout"><aside><button id="closeFilters">✕ Done — show products</button><input id="q" type="search" placeholder="Search title / SKU / pattern…"><div id="facets"></div><button id="clear">Clear filters</button></aside><main><div id="grid" class="grid"></div><button id="more">Load more</button><div id="empty" hidden>No products match.</div></main></div>
<script>
const $=s=>document.querySelector(s), grid=$('#grid');
const state={q:'',sort:'newest',offset:0,total:0,loading:false,filters:{type:'',series:'',color:'',style:'',material:'',lifecycle:''}};
const PAGE=120, pref='vendor-internal.'+location.hostname+'.';
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const fmtDate=v=>{if(!v)return 'Created time unavailable';const d=new Date(Number(v)||v);return Number.isNaN(d.getTime())?'Created time unavailable':d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});};
const apiKey={type:'types',series:'series',color:'colors',style:'styles',material:'materials',lifecycle:'lifecycles'};
const params=()=>{const p=new URLSearchParams({offset:state.offset,limit:PAGE,sort:state.sort});if(state.q)p.set('q',state.q);for(const[k,v]of Object.entries(state.filters))if(v)p.set(apiKey[k],v);return p;};
function card(p){const life=String(p.lifecycle||'').toLowerCase(),created=p.created||p.updated;return `<article class="card"><div class="pic">${p.image?`<img loading="lazy" src="${esc(p.image)}" alt="${esc(p.title)}">`:''}${p.images&&p.images[1]?`<img class="alt" loading="lazy" src="${esc(p.images[1])}" alt="">`:''}</div><div class="body"><div class="title">${esc(p.title||p.pattern||p.sku||'(untitled)')}</div><div class="sku">${esc(p.sku||'—')}${p.mfr_number?' · '+esc(p.mfr_number):''}</div><div class="chips"><span class="chip ${life.includes('active')?'active':life.includes('staged')?'staged':''}">${esc(p.lifecycle||p.status||'Unknown')}</span>${(p.styles||[]).slice(0,2).map(x=>`<span class="chip">${esc(x)}</span>`).join('')}${(p.colors||[]).slice(0,1).map(x=>`<span class="chip">${esc(x)}</span>`).join('')}</div><time class="when" datetime="${created?new Date(Number(created)||created).toISOString():''}" title="${created?new Date(Number(created)||created).toISOString():''}">🕓 ${fmtDate(created)}</time><div class="actions">${p.internal_url?`<a href="${esc(p.internal_url)}" target="_blank" rel="noopener">Shopify admin ↗</a>`:''}${p.url?`<a href="${esc(p.url)}" target="_blank" rel="noopener">Storefront ↗</a>`:''}</div></div></article>`;}
let requestSeq=0;
async function load(reset=false){if(state.loading&&!reset)return;const seq=++requestSeq;state.loading=true;if(reset){state.offset=0;grid.innerHTML='';}try{const r=await fetch('/api/products?'+params());if(!r.ok)throw Error('HTTP '+r.status);const d=await r.json();if(seq!==requestSeq)return;state.total=d.total;grid.insertAdjacentHTML('beforeend',d.rows.map(card).join(''));state.offset+=d.rows.length;$('#count').textContent=d.total.toLocaleString()+' products';$('#meta').textContent=state.offset.toLocaleString()+' shown · dw_unified';$('#more').hidden=state.offset>=d.total;$('#empty').hidden=d.total!==0;}catch(e){if(seq===requestSeq)$('#meta').textContent='Catalog error: '+e.message;}finally{if(seq===requestSeq)state.loading=false;}}
async function loadFacets(){const f=await fetch('/api/facets').then(r=>r.json());const defs=[['type','Types'],['series','Books / series'],['color','Colors'],['style','Styles'],['material','Materials'],['lifecycle','Lifecycle']];$('#facets').innerHTML=defs.map(([k,label])=>`<details><summary>${label}</summary><div class="facet">${Object.entries(f[k]||{}).slice(0,40).map(([v,n])=>`<button class="frow${state.filters[k]===v?' on':''}" data-k="${k}" data-v="${esc(v)}"><span>${esc(v)}</span><span>${Number(n).toLocaleString()}</span></button>`).join('')}</div></details>`).join('');}
async function boot(){const c=await fetch('/api/vendor-context').then(r=>r.json());document.title=c.vendor+' · Internal Catalog · dw_unified';$('#brand').textContent=c.vendor+' · Catalog';state.sort=localStorage.getItem(pref+'sort')||'newest';$('#sort').value=state.sort;const den=localStorage.getItem(pref+'density')||'6';$('#density').value=den;document.documentElement.style.setProperty('--cols',den);if(localStorage.getItem(pref+'view')==='list'){grid.classList.add('list');$('#view').textContent='▦ Grid view';}await Promise.all([loadFacets(),load(true)]);}
$('#facets').onclick=e=>{const b=e.target.closest('.frow');if(!b)return;state.filters[b.dataset.k]=state.filters[b.dataset.k]===b.dataset.v?'':b.dataset.v;loadFacets();load(true);};
$('#clear').onclick=()=>{state.q='';$('#q').value='';for(const k in state.filters)state.filters[k]='';loadFacets();load(true);};let qt;$('#q').oninput=()=>{clearTimeout(qt);qt=setTimeout(()=>{state.q=$('#q').value.trim();load(true);},250);};
$('#sort').onchange=()=>{state.sort=$('#sort').value;localStorage.setItem(pref+'sort',state.sort);load(true);};$('#density').oninput=()=>{document.documentElement.style.setProperty('--cols',$('#density').value);localStorage.setItem(pref+'density',$('#density').value);};
$('#view').onclick=()=>{grid.classList.toggle('list');localStorage.setItem(pref+'view',grid.classList.contains('list')?'list':'grid');$('#view').textContent=grid.classList.contains('list')?'▦ Grid view':'▤ List view';};$('#more').onclick=()=>load();addEventListener('scroll',()=>{if(innerHeight+scrollY>document.body.offsetHeight-700)load();});
$('#filtersBtn').onclick=()=>document.querySelector('aside').classList.add('open');$('#closeFilters').onclick=()=>document.querySelector('aside').classList.remove('open');boot();
</script>
</body></html>