← back to Pattern Vault

public/index.html

202 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">
<title>The Pattern Vault — license original designs</title>
<style>
:root{--bg:#0d0b10;--card:#17141d;--ink:#efe9f7;--mut:#9a8fb0;--gold:#d4af6a;--line:#2a2436;--cols:4}
*{box-sizing:border-box;margin:0}
body{background:var(--bg);color:var(--ink);font:15px/1.55 "Avenir Next",system-ui,sans-serif}
header{padding:56px 6vw 28px;text-align:center;border-bottom:1px solid var(--line)}
h1{font-size:clamp(28px,4.5vw,52px);font-weight:600;letter-spacing:.12em;text-transform:uppercase}
h1 em{color:var(--gold);font-style:normal}
.tag{color:var(--mut);font-style:italic;margin-top:8px}
.badge{display:inline-block;margin-top:14px;padding:4px 14px;border:1px solid var(--gold);border-radius:99px;color:var(--gold);font-size:12px;letter-spacing:.08em}
.controls{display:flex;gap:14px;align-items:center;justify-content:center;padding:18px 6vw;flex-wrap:wrap}
select,input[type=range]{background:var(--card);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px}
.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:18px;padding:10px 6vw 60px}
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;overflow:hidden;transition:transform .15s}
.card:hover{transform:translateY(-3px);border-color:var(--gold)}
.card{position:relative}
.card.orig{border-color:#d4af6a55}
.origtag{position:absolute;top:8px;left:8px;background:#0d0a16e0;border:1px solid var(--gold);color:var(--gold);font-size:9.5px;letter-spacing:.09em;font-weight:600;padding:3px 7px;border-radius:99px}
.roomtag{position:absolute;bottom:8px;right:8px;background:#0d0a16d0;border:1px solid var(--line);color:var(--mut);font-size:9.5px;padding:3px 7px;border-radius:99px;pointer-events:none}
.card:hover .roomtag{color:var(--gold);border-color:var(--gold)}
#inhouseBand b{color:var(--gold)}
.card img{width:100%;aspect-ratio:1;object-fit:cover;display:block;background:#221d2c}
.pad{padding:12px 14px 16px}
.t{font-weight:600;font-size:14px}
.s{color:var(--mut);font-size:12.5px;margin:5px 0 10px;min-height:34px}
.tiers{display:flex;gap:6px;flex-wrap:wrap}
.tiers button{background:transparent;border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:5px 9px;font-size:12px;cursor:pointer}
.tiers button:hover{border-color:var(--gold);color:var(--gold)}
.price{color:var(--gold)}
dialog{background:var(--card);color:var(--ink);border:1px solid var(--gold);border-radius:14px;padding:26px;max-width:420px}
dialog::backdrop{background:#000a}
dialog input{width:100%;margin:10px 0;background:var(--bg);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:9px}
dialog button{background:var(--gold);border:0;border-radius:8px;padding:10px 18px;font-weight:600;cursor:pointer}
.note{color:var(--mut);font-size:12px;margin-top:10px}
footer{border-top:1px solid var(--line);color:var(--mut);text-align:center;padding:26px;font-size:13px}
footer a{color:var(--gold)}

/* ── Grid / List toggle ── */
.vtoggle{display:flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
.vtoggle button{background:var(--card);color:var(--mut);border:none;padding:7px 14px;font:13px/1 inherit;cursor:pointer}
.vtoggle button.on{background:var(--gold);color:#1a1208;font-weight:700}

/* ── List view ── */
.grid.list{display:block;padding:10px 6vw 60px}
.grid.list .card{display:flex;flex-direction:row;align-items:stretch;margin-bottom:10px;border-radius:10px;transform:none!important}
.grid.list .card:hover{transform:none!important}
.grid.list .card img{width:80px;min-width:80px;height:80px;aspect-ratio:auto;object-fit:cover}
.grid.list .card .pad{flex:1;padding:10px 14px;display:flex;flex-direction:column;justify-content:center;gap:4px}
.grid.list .origtag{top:6px;left:6px}
.grid.list .roomtag{display:none}

/* ── Fields menu ── */
.fieldmenu{position:relative}
.fbtn{background:var(--card);color:var(--mut);border:1px solid var(--line);border-radius:8px;padding:7px 14px;font:13px/1 inherit;cursor:pointer}
.fbtn:hover{border-color:var(--gold);color:var(--ink)}
.fpop{position:absolute;top:calc(100% + 8px);right:0;background:var(--card);border:1px solid var(--line);border-radius:10px;box-shadow:0 12px 30px #0008;padding:8px 4px;z-index:30;min-width:176px}
.fpop[hidden]{display:none}
.fpop label{display:flex;align-items:center;gap:10px;padding:6px 14px;font-size:13px;color:var(--mut);cursor:pointer;white-space:nowrap}
.fpop label:hover{color:var(--ink)}
.fpop input{accent-color:var(--gold)}

/* ── Field-hide rules (grid mode only) ── */
.grid:not(.list).hf-title  .card .t     {display:none}
.grid:not(.list).hf-style  .card .s     {display:none}
.grid:not(.list).hf-tiers  .card .tiers {display:none}
</style></head><body>
<header>
  <h1>The <em>Pattern</em> Vault</h1>
  <div class="tag">Original seamless wallpaper &amp; surface patterns — hand-designed, print-ready, licensed by the roll or exclusive</div>
  <div class="badge" id="modeBadge">loading…</div>
  <div style="margin-top:14px"><a href="/trends" style="color:var(--gold);text-decoration:none;font-size:13px;letter-spacing:.06em;border-bottom:1px solid var(--line);padding-bottom:2px">Trend Board →</a> &nbsp; <a href="/handoff" style="color:var(--gold);text-decoration:none;font-size:13px;letter-spacing:.06em;border-bottom:1px solid var(--line);padding-bottom:2px">Handoff →</a></div>
</header>
<div class="controls">
  <label>Sort <select id="sortSel">
    <option value="newest">Newest</option><option value="title">Title A→Z</option>
    <option value="category">Style</option><option value="color">Color</option>
    <option value="id">ID A→Z</option>
  </select></label>
  <label>Density <input type="range" id="density" min="2" max="6" value="4"></label>
  <div class="vtoggle">
    <button id="vGrid" class="on" onclick="setView('grid')">▦ Grid</button>
    <button id="vList" onclick="setView('list')">☰ List</button>
  </div>
  <div class="fieldmenu">
    <button class="fbtn" onclick="toggleFieldsMenu(event)">Fields ▾</button>
    <div class="fpop" id="fpop" hidden>
      <label><input type="checkbox" data-pf="title" checked> Title</label>
      <label><input type="checkbox" data-pf="style" checked> Style line</label>
      <label><input type="checkbox" data-pf="tiers" checked> License tiers</label>
    </div>
  </div>
</div>
<div id="inhouseBand" style="text-align:center;color:var(--mut);font-size:13px;padding:0 6vw 6px"></div>
<div class="grid" id="grid"></div>
<footer>Printed wallpaper of these designs → <a href="https://wallpapersback.com" rel="noopener noreferrer">wallpapersback.com</a> · Every design is an original from our studio pipeline, seam-verified and print-clean at 150 DPI. · <a href="/setup.html">setup guide</a></footer>
<dialog id="dlg"><div id="dlgTitle" class="t"></div><div class="s" id="dlgTier"></div>
  <input id="email" type="email" placeholder="your email">
  <button id="goBtn">Continue</button><div class="note" id="dlgNote"></div></dialog>
<script>
let items=[],cfg={};
const grid=document.getElementById('grid'),dlg=document.getElementById('dlg');
const sortSel=document.getElementById('sortSel'),density=document.getElementById('density');
sortSel.value=localStorage.pvSort||'newest';density.value=localStorage.pvCols||'4';
document.documentElement.style.setProperty('--cols',density.value);
sortSel.onchange=()=>{localStorage.pvSort=sortSel.value;render()};
density.oninput=()=>{localStorage.pvCols=density.value;document.documentElement.style.setProperty('--cols',density.value)};

// ── Grid / List toggle ──
const PV_FIELD_DEFAULTS={title:true,style:true,tiers:true};
function setView(v){
  const g=document.getElementById('grid');
  g.classList.toggle('list',v==='list');
  document.getElementById('vGrid').classList.toggle('on',v!=='list');
  document.getElementById('vList').classList.toggle('on',v==='list');
  localStorage.pvView=v;
}
(function(){setView(localStorage.pvView||'grid')})();

// ── Fields menu ──
function getPVFields(){
  try{return Object.assign({},PV_FIELD_DEFAULTS,JSON.parse(localStorage.pvFields||'{}'));}
  catch(e){return Object.assign({},PV_FIELD_DEFAULTS);}
}
function applyPVFields(prefs){
  const g=document.getElementById('grid');
  for(const[k,on]of Object.entries(prefs))g.classList.toggle('hf-'+k,!on);
  document.querySelectorAll('#fpop [data-pf]').forEach(cb=>{cb.checked=!!prefs[cb.dataset.pf];});
}
function toggleFieldsMenu(e){
  e.stopPropagation();
  const p=document.getElementById('fpop');
  p.hidden=!p.hidden;
}
document.addEventListener('click',()=>{const p=document.getElementById('fpop');if(p)p.hidden=true;});
document.querySelectorAll('#fpop [data-pf]').forEach(cb=>{
  cb.addEventListener('change',()=>{
    const prefs=getPVFields();
    prefs[cb.dataset.pf]=cb.checked;
    localStorage.pvFields=JSON.stringify(prefs);
    applyPVFields(prefs);
  });
});
applyPVFields(getPVFields());
function sorted(){const s=sortSel.value,a=[...items];
  if(s==='title')a.sort((x,y)=>x.title.localeCompare(y.title));
  else if(s==='category')a.sort((x,y)=>(x.category||'').localeCompare(y.category||''));
  else if(s==='color')a.sort((x,y)=>(x.dominantHex||'').localeCompare(y.dominantHex||''));
  else if(s==='id')a.sort((x,y)=>String(x.id||'').localeCompare(String(y.id||'')));
  else a.sort((x,y)=>(y.createdAt||'').localeCompare(x.createdAt||''));return a}
function render(){const n=items.filter(x=>x.category==='in-house-original').length;
  const band=document.getElementById('inhouseBand');
  if(band)band.innerHTML=n?`<b>✦ ${n} In-House Originals</b> — designed in our own hand from live marketplace trend research, every one settlement-cleared. The rest are licensed Wallpaper's Back designs.`:'';
  grid.innerHTML=sorted().map(d=>{const orig=d.category==='in-house-original';
  const hov=d.roomImg?` onmouseenter="this.querySelector('img').src='${d.roomImg}'" onmouseleave="this.querySelector('img').src='${d.img}'"`:'';
  return `<div class="card${orig?' orig':''}"${hov}><img loading="lazy" src="${d.img}" alt="${d.title}">${orig?'<span class="origtag">✦ IN-HOUSE ORIGINAL</span>':''}${d.roomImg?'<span class="roomtag">🛋 see in a room</span>':''}<div class="pad">
  <div class="t">${d.title}</div><div class="s">${d.styleLine||''}</div>
  <div class="tiers">${(d.licenseTiers||[]).map(t=>`<button onclick='pick(${JSON.stringify(d.id)},${JSON.stringify(t.tier)})'>${t.label||t.tier} <span class="price">$${t.priceUsd??'—'}</span></button>`).join('')}</div>
  </div></div>`}).join('')}
let sel={};
function pick(id,tier){const d=items.find(x=>String(x.id)===String(id));sel={id,tier};
  document.getElementById('dlgTitle').textContent=d.title;
  document.getElementById('dlgTier').textContent=(d.licenseTiers.find(t=>t.tier===tier)||{}).label+' license';
  document.getElementById('dlgNote').textContent=cfg.note||'';dlg.showModal()}
document.getElementById('goBtn').onclick=async()=>{
  try{
    const r=await fetch('/api/license/checkout',{method:'POST',headers:{'Content-Type':'application/json'},
      body:JSON.stringify({designId:sel.id,tier:sel.tier,email:document.getElementById('email').value})});
    const j=await r.json();
    if(j.url)location=j.url;else{document.getElementById('dlgNote').textContent=j.note||j.error;setTimeout(()=>dlg.close(),1800)}
  }catch(e){document.getElementById('dlgNote').textContent='Network error — try again'}};
(async()=>{cfg=await(await fetch('/api/config')).json();
  document.getElementById('modeBadge').textContent=cfg.mode==='test'?'TEST MODE — no real charges':'Preview — inquiries only until checkout opens';
  items=(await(await fetch('/api/catalog')).json()).items;render()})();
</script></body></html>