← back to Trending Dw
public/index.html
414 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Trending — Designer Wallcoverings</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%232f5d3a'/%3E%3Cpath d='M7 21l5-8 4 5 3-4 6 7z' fill='%23faf8f3'/%3E%3Ccircle cx='11' cy='9' r='2.5' fill='%23b6392f'/%3E%3C/svg%3E"/>
<style>
:root{ --cols:4; --ink:#1c1a17; --paper:#faf8f3; --line:#e4ded2; --accent:#2f5d3a; --gap:#b6392f; --muted:#8a8272; --chip:#f0ebe0; }
*{ box-sizing:border-box; }
body{ margin:0; font-family:"Helvetica Neue",Arial,sans-serif; color:var(--ink); background:var(--paper); -webkit-font-smoothing:antialiased; }
header.top{ display:flex; align-items:baseline; flex-wrap:wrap; gap:8px 18px; padding:18px 26px 14px; border-bottom:1px solid var(--line); position:sticky; top:0; background:var(--paper); z-index:20; }
header.top .tag{ min-width:0; }
header.top .wm{ font-weight:800; letter-spacing:.14em; text-transform:uppercase; font-size:15px; }
header.top .wm small{ display:block; font-weight:400; letter-spacing:.22em; font-size:9px; color:var(--muted); margin-top:3px; }
header.top .tag{ font-size:12px; color:var(--muted); font-style:italic; }
header.top .fresh{ margin-left:auto; display:flex; align-items:center; gap:8px; font-size:11px; letter-spacing:.02em; white-space:nowrap; }
header.top .fresh .dot{ width:8px; height:8px; border-radius:50%; flex:0 0 auto; }
header.top .fresh .lbl{ color:var(--muted); } header.top .fresh .lbl b{ color:var(--ink); font-variant-numeric:tabular-nums; }
header.top .fresh.fresh-ok .dot{ background:#1f7a4d; box-shadow:0 0 0 3px rgba(31,122,77,.16); }
header.top .fresh.fresh-warn .dot{ background:#b8860b; box-shadow:0 0 0 3px rgba(184,134,11,.18); }
header.top .fresh.fresh-stale .dot{ background:var(--gap); box-shadow:0 0 0 3px rgba(182,57,47,.18); }
header.top .fresh.fresh-warn .age, header.top .fresh.fresh-stale .age{ font-weight:700; }
header.top .fresh.fresh-warn .age{ color:#b8860b; } header.top .fresh.fresh-stale .age{ color:var(--gap); }
.layout{ display:grid; grid-template-columns:258px 1fr; align-items:start; }
aside{ position:sticky; top:64px; align-self:start; height:calc(100vh - 64px); overflow:auto; padding:16px 14px 40px; border-right:1px solid var(--line); }
aside .search{ width:100%; padding:9px 11px; border:1px solid var(--line); border-radius:7px; font-size:13px; margin-bottom:12px; background:#fff; }
.facet{ margin-bottom:15px; }
.facet h4{ margin:0 0 7px; font-size:11px; letter-spacing:.12em; text-transform:uppercase; color:var(--muted); }
.facet a{ display:flex; justify-content:space-between; gap:8px; padding:4px 6px; font-size:12.5px; text-decoration:none; border-radius:5px; cursor:pointer; }
.facet a:hover{ background:#fff; }
.facet a.active{ background:var(--accent); color:#fff; font-weight:600; }
.facet a.active.gapf{ background:var(--gap); }
.facet a .c{ color:var(--muted); font-variant-numeric:tabular-nums; }
.facet a.active .c{ color:#fff; opacity:.85; }
main{ padding:16px 26px 60px; }
.bar{ display:flex; align-items:center; gap:14px; flex-wrap:wrap; margin-bottom:10px; }
.bar .count{ font-size:13px; color:var(--muted); } .bar .count b{ color:var(--ink); }
.bar .gapcount{ font-size:12px; color:var(--gap); font-weight:700; }
.bar label{ font-size:11px; letter-spacing:.1em; text-transform:uppercase; color:var(--muted); margin-right:5px; }
.bar select{ padding:7px 9px; border:1px solid var(--line); border-radius:7px; font-size:13px; background:#fff; }
.bar .dens{ display:flex; align-items:center; gap:8px; margin-left:auto; }
.bar input[type=range]{ width:120px; accent-color:var(--accent); }
.toggle{ display:inline-flex; border:1px solid var(--line); border-radius:8px; overflow:hidden; }
.toggle button{ border:0; background:#fff; padding:7px 13px; font-size:12px; cursor:pointer; color:var(--muted); }
.toggle button.on{ background:var(--accent); color:#fff; }
.chipsbar{ display:flex; flex-wrap:wrap; gap:6px; margin-bottom:14px; min-height:0; }
.fchip{ font-size:11px; background:var(--ink); color:#fff; padding:4px 9px; border-radius:20px; cursor:pointer; }
.fchip:hover{ background:var(--gap); }
.grid{ display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:16px; }
.card{ border:1px solid var(--line); border-radius:12px; overflow:hidden; background:#fff; display:flex; flex-direction:column; transition:box-shadow .15s, transform .15s; }
.card:hover{ box-shadow:0 8px 24px rgba(0,0,0,.09); transform:translateY(-2px); }
.swatch{ aspect-ratio:4/3; position:relative; }
.swatch .ribbon{ position:absolute; top:10px; left:-30px; transform:rotate(-45deg); background:#b6392f; color:#fff; font-size:10px; letter-spacing:.14em; padding:3px 34px; font-weight:700; }
.swatch .rank{ position:absolute; bottom:8px; right:9px; background:rgba(0,0,0,.55); color:#fff; font-size:10px; padding:2px 7px; border-radius:20px; }
.swatch .ours{ position:absolute; bottom:8px; left:9px; max-width:62%; background:rgba(0,0,0,.55); color:#fff; font-size:9.5px; padding:2px 7px; border-radius:20px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.swatch .cov{ position:absolute; top:9px; right:9px; font-size:9.5px; font-weight:800; letter-spacing:.08em; padding:3px 8px; border-radius:20px; }
/* pending affordance for cards with no our-own image (GAP lanes awaiting generation) */
.swatch .pending{ position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:5px; color:rgba(255,255,255,.92); text-align:center; padding:12px; }
.swatch .pending .ico{ font-size:22px; opacity:.9; }
.swatch .pending .txt{ font-size:10px; letter-spacing:.06em; text-transform:uppercase; font-weight:700; }
.swatch .pending .sub{ font-size:9px; opacity:.8; letter-spacing:.02em; }
.spotted{ font-size:10px; color:var(--muted); display:flex; align-items:center; gap:4px; font-variant-numeric:tabular-nums; }
.cov.gap{ background:var(--gap); color:#fff; } .cov.covered{ background:rgba(255,255,255,.82); color:var(--accent); }
/* real-product hero: fades in on load; shimmer overlay while fetching */
.swatch img.hero{ position:absolute; inset:0; width:100%; height:100%; object-fit:cover; opacity:0; transition:opacity .25s; }
.swatch img.hero.ld{ opacity:1; }
.swatch .shimmer{ position:absolute; inset:0; background:linear-gradient(100deg,rgba(255,255,255,0) 30%,rgba(255,255,255,.35) 50%,rgba(255,255,255,0) 70%); background-size:200% 100%; animation:shim 1.1s linear infinite; pointer-events:none; }
@keyframes shim{ 0%{ background-position:180% 0; } 100%{ background-position:-80% 0; } }
.swatch .prov{ position:absolute; bottom:8px; right:9px; background:rgba(0,0,0,.62); color:#fff; font-size:9.5px; letter-spacing:.06em; font-weight:700; padding:3px 8px; border-radius:20px; }
/* our-own coverage design, inset over the competitor's real product image */
.swatch .oursthumb{ position:absolute; bottom:8px; left:9px; width:58px; text-align:center; }
/* duplicate our-lane fallback: same design already full-bleed on an earlier card, so
render a centered inset on the tint instead of repeating the identical full-bleed */
.swatch .lanewrap{ position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:8px; padding:12px; text-align:center; }
.swatch .lanewrap img{ width:46%; aspect-ratio:1; object-fit:cover; border-radius:10px; border:2px solid rgba(255,255,255,.92); box-shadow:0 3px 12px rgba(0,0,0,.28); }
.swatch .lanewrap .lanename{ font-size:9.5px; font-weight:700; letter-spacing:.04em; color:#fff; background:rgba(0,0,0,.45); padding:3px 9px; border-radius:20px; max-width:92%; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.swatch .oursthumb img{ width:58px; height:58px; object-fit:cover; border-radius:8px; border:2px solid rgba(255,255,255,.95); box-shadow:0 2px 8px rgba(0,0,0,.35); display:block; }
.swatch .oursthumb .lb{ font-size:8px; font-weight:800; letter-spacing:.12em; color:#fff; background:rgba(0,0,0,.55); border-radius:6px; padding:1px 0; margin-top:3px; }
.errbar{ background:#fdf2f1; border:1px solid #ecc8c4; color:#8f2b23; font-size:13px; padding:12px 14px; border-radius:10px; display:flex; align-items:center; gap:12px; margin:10px 0; }
.errbar button{ border:1px solid #b6392f; background:#b6392f; color:#fff; border-radius:8px; padding:6px 12px; cursor:pointer; font-size:12px; }
.empty{ color:var(--muted); font-size:13px; padding:34px 10px; text-align:center; grid-column:1/-1; }
.empty a{ color:var(--gap); cursor:pointer; text-decoration:underline; }
.body{ padding:11px 12px 13px; display:flex; flex-direction:column; gap:6px; flex:1; }
.title{ font-size:14px; font-weight:700; line-height:1.25; }
.company{ font-size:12px; } .company b{ color:var(--accent); }
.chips{ display:flex; flex-wrap:wrap; gap:5px; margin-top:2px; }
.chip{ font-size:10px; padding:3px 7px; border-radius:20px; background:var(--chip); color:#5a5346; }
.shopbtn{ margin-top:9px; width:100%; border:1px solid #1f7a4d; background:#1f7a4d; color:#fff; font-size:12px; font-weight:600; padding:7px 10px; border-radius:8px; cursor:pointer; letter-spacing:.02em; }
.shopbtn:hover{ background:#18633e; }
.shopbtn.done{ background:#eef4ef; color:#1f7a4d; border-color:#bcd8c7; cursor:default; }
.queuebtn{ border:1px solid #d9d2c4; background:#fff; color:#5a5346; font-size:12px; padding:5px 10px; border-radius:8px; cursor:pointer; }
.queuebtn b{ color:#1f7a4d; }
.chip.mk{ background:#1c1a17; color:#fff; }
.signal{ font-size:11.5px; color:var(--muted); } .signal .dot{ color:var(--accent); }
.tier{ font-size:11px; color:var(--ink); background:#f6f2e8; border:1px dashed var(--line); border-radius:6px; padding:3px 7px; margin-top:auto; }
.src{ font-size:11px; color:var(--accent); text-decoration:none; }
.src:hover{ text-decoration:underline; }
/* lanes table */
table.lanes{ width:100%; border-collapse:collapse; font-size:13px; }
table.lanes th{ text-align:left; font-size:10px; letter-spacing:.1em; text-transform:uppercase; color:var(--muted); border-bottom:1px solid var(--line); padding:8px 10px; }
table.lanes td{ padding:9px 10px; border-bottom:1px solid var(--line); }
table.lanes tr:hover td{ background:#fff; }
.pill{ font-size:10px; font-weight:700; padding:3px 8px; border-radius:20px; }
.pill.gap{ background:var(--gap); color:#fff; } .pill.covered{ background:#e6ede7; color:var(--accent); }
.bars{ display:inline-block; height:8px; border-radius:4px; background:var(--accent); vertical-align:middle; }
#sentinel{ height:40px; } .loading{ text-align:center; color:var(--muted); font-size:12px; padding:20px; }
@media(max-width:820px){ .layout{ grid-template-columns:1fr; } aside{ position:static; height:auto; border-right:none; border-bottom:1px solid var(--line); } }
</style>
</head>
<body>
<header class="top">
<div class="wm">Designer Wallcoverings<small>Trending Intelligence</small></div>
<div class="tag">Bestsellers across Google & print-on-demand — who's selling what, and where we have a gap</div>
<div class="fresh" id="fresh" title="Data freshness — a true refresh is metered & gated"></div>
</header>
<div class="layout">
<aside>
<input class="search" id="search" placeholder="Search item, company, style…" />
<div id="facets"></div>
</aside>
<main>
<div class="bar">
<div class="count"><b id="cnt">0</b> data points</div>
<div class="gapcount" id="gapcnt"></div>
<div class="toggle"><button id="vGrid" class="on">Grid</button><button id="vLanes">Lanes</button></div>
<button class="queuebtn" id="queueBtn" onclick="openQueue()">🛍 Shopify queue <b id="qn">0</b></button>
<div><label>Sort</label>
<select id="sort">
<option value="have">Real images first</option>
<option value="signal">Bestseller signal</option>
<option value="gap">Gaps first</option>
<option value="newest">Newest</option>
<option value="titleAZ">Title A–Z</option>
<option value="company">Company</option>
<option value="marketplace">Marketplace</option>
</select>
</div>
<div class="dens"><label>Density</label><input type="range" id="dens" min="2" max="6" step="1" value="4"/></div>
</div>
<div class="chipsbar" id="chipsbar"></div>
<div id="errbar"></div>
<div class="grid" id="grid"></div>
<div id="lanes"></div>
<div id="sentinel"></div>
<div class="loading" id="loading" style="display:none">Loading…</div>
</main>
</div>
<script>
const store = k => { try{ return JSON.parse(localStorage.getItem(k)); }catch(e){ return null; } };
// one-time migration: 'have' (Imaged first) was the default before real product images
// existed; now nearly everything is imaged, so 'signal' is the meaningful default.
const savedSort = (()=>{ let s=localStorage.getItem('tdw_sort');
if(s==='have'&&!localStorage.getItem('tdw_sort_mig')){ s='signal'; localStorage.setItem('tdw_sort','signal'); localStorage.setItem('tdw_sort_mig','1'); }
// migration 2: real product images exist now — 'have' is tiered (real > our-lane > pending)
// and beats 'signal' as the default (signal interleaves placeholder tiles into the top fold).
if(s==='signal'&&!localStorage.getItem('tdw_sort_mig2')){ s='have'; localStorage.setItem('tdw_sort','have'); localStorage.setItem('tdw_sort_mig2','1'); }
return s||'have'; })();
const state = {
filters: store('tdw_filters2') || {}, // {dim: [values]}
q:'', sort: savedSort, view: localStorage.getItem('tdw_view')||'grid',
page:1, size:36, loading:false, done:false
};
const DIMS = [['ourCoverage','Coverage'],['marketplace','Marketplace'],['company','Company'],['style','Style'],['color','Color'],['priceBand','Price']];
const has = (dim,val) => (state.filters[dim]||[]).includes(val);
function toggle(dim,val){ const a = state.filters[dim]||[]; state.filters[dim] = has(dim,val)? a.filter(x=>x!==val) : [...a,val]; if(!state.filters[dim].length) delete state.filters[dim]; save(); loadFacets(); chips(); refresh(); }
function save(){ localStorage.setItem('tdw_filters2', JSON.stringify(state.filters)); localStorage.setItem('tdw_sort', state.sort); localStorage.setItem('tdw_view', state.view); }
async function loadFacets(){
let f;
try{ const r = await fetch('/api/facets'); if(!r.ok) throw new Error('HTTP '+r.status); f = await r.json(); }
catch(e){ showError('Couldn’t load facets ('+e.message+').'); return; }
const el = document.getElementById('facets'); el.innerHTML='';
for (const [key,label] of DIMS){
const rows=(f[key]||[]); if(!rows.length) continue;
const box=document.createElement('div'); box.className='facet'; box.innerHTML='<h4>'+label+'</h4>';
for (const {value,count} of rows){
const a=document.createElement('a');
const active=has(key,value); const gapf=(key==='ourCoverage'&&value==='gap');
a.className=(active?'active ':'')+(gapf?'gapf':'');
const lbl = value==='gap'?'🎯 Gap (we don’t have)' : value==='covered'?'✓ Covered' : value;
a.innerHTML='<span>'+lbl+'</span><span class="c">'+count+'</span>';
a.onclick=()=>toggle(key,value); box.appendChild(a);
}
el.appendChild(box);
}
}
function chips(){
const bar=document.getElementById('chipsbar'); bar.innerHTML='';
const entries=Object.entries(state.filters).flatMap(([d,vals])=>vals.map(v=>[d,v]));
if(state.q) entries.unshift(['q',state.q]);
for(const [d,v] of entries){ const c=document.createElement('span'); c.className='fchip'; c.textContent='✕ '+(v==='gap'?'Gap':v==='covered'?'Covered':v); c.onclick=()=>{ if(d==='q'){state.q='';document.getElementById('search').value='';} else toggle(d,v); if(d==='q'){save();chips();refresh();} }; bar.appendChild(c); }
if(entries.length){ const c=document.createElement('span'); c.className='fchip'; c.style.background='var(--gap)'; c.textContent='Clear all'; c.onclick=()=>{ state.filters={};state.q='';document.getElementById('search').value='';save();loadFacets();chips();refresh(); }; bar.appendChild(c); }
}
function qs(extra){ const p=new URLSearchParams(); for(const d in state.filters) p.set(d, state.filters[d].join(',')); if(state.q) p.set('q',state.q); p.set('sort',state.sort); for(const k in (extra||{})) p.set(k,extra[k]); return p.toString(); }
function esc(s){ return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
function fmtSpotted(d){ try{ const dt=new Date(d+'T00:00:00'); return dt.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric'}); }catch(e){ return d; } }
async function loadMeta(){
try{
const m = await (await fetch('/api/meta')).json();
const el = document.getElementById('fresh'); if(!el) return;
const d = m.staleDays==null ? null : m.staleDays;
const tier = d==null||d<=3 ? 'ok' : d<=7 ? 'warn' : 'stale';
el.className = 'fresh fresh-'+tier;
const age = d==null ? '—' : d===0 ? 'today' : d===1 ? '1 day ago' : d+' days ago';
const asOf = m.asOf ? fmtSpotted(m.asOf) : '—';
el.innerHTML = '<span class="dot"></span><span class="lbl">as of <b>'+esc(asOf)+'</b> · <span class="age">'+esc(age)+'</span> · <b>'+(m.count||0)+'</b> signals'+(m.withRealImage?' · <b>'+m.withRealImage+'</b> real imgs':'')+(m.realImageGap?' · <b>'+m.realImageGap+'</b> img-gap':'')+'</span>';
el.title = 'Data as of '+asOf+' ('+age+'). A true trend refresh is metered & gated — this board reflects the last approved research pull.';
}catch(e){}
}
let seenLane = new Set(); // our-lane designs already full-bled this render (reset per refresh)
function shade(h,p){ const n=parseInt(h.slice(1),16); let r=(n>>16)+p,g=((n>>8)&255)+p,b=(n&255)+p; r=Math.max(0,Math.min(255,r));g=Math.max(0,Math.min(255,g));b=Math.max(0,Math.min(255,b)); return '#'+((1<<24)+(r<<16)+(g<<8)+b).toString(16).slice(1); }
function fmtWhen(iso){ try{ return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }catch(e){ return iso; } }
function card(it){
const c=document.createElement('div'); c.className='card';
const grad='linear-gradient(135deg,'+it.dominantHex+' 0%,'+shade(it.dominantHex,-18)+' 100%)';
const pending='<div class="pending"><div class="ico">🎨</div><div class="txt">Product image pending</div><div class="sub">'+(it.ourCoverage==='gap'?'GAP — real image unresolved':'match in progress')+'</div></div>';
// hero = the REAL marketplace product image when we have one; else our own coverage design.
// Fallbacks full-bleed only ONCE per design per render — dozens of items map to the same
// our-lane design (41x Apricot Garden), and repeating it full-bleed reads as a broken grid.
const laneKey = (!it.realImage && it.image) ? (it.imageDesign || it.image) : null;
const laneDup = laneKey ? seenLane.has(laneKey) : false;
if (laneKey && !laneDup) seenLane.add(laneKey);
const hero = it.realImage || (laneDup ? null : it.image);
const laneInset = laneDup
? '<div class="lanewrap" title="Our lane coverage'+(it.imageDesign?': '+esc(it.imageDesign):'')+' — real product image pending"><img src="'+esc(it.image)+'" alt="Our design'+(it.imageDesign?': '+esc(it.imageDesign):'')+'" loading="lazy"><div class="lanename">◆ our lane: '+esc(it.imageDesign||'coverage design')+'</div></div>' : '';
const prov = it.realImage ? '<div class="prov" title="Real product image — '+esc(it.realImageSource||it.marketplace)+(it.realImageFetchedAt?' · fetched '+esc(fmtWhen(it.realImageFetchedAt)):'')+'">▣ '+esc(it.marketplace)+'</div>' : '';
const oursInset = (it.realImage && it.image && it.isOurOriginal)
? '<div class="oursthumb" title="Our design'+(it.imageDesign?': '+esc(it.imageDesign):'')+'"><img src="'+esc(it.image)+'" alt="Our design'+(it.imageDesign?': '+esc(it.imageDesign):'')+'" loading="lazy"><div class="lb">OURS</div></div>' : '';
const oursPill = (!it.realImage && !laneDup && it.isOurOriginal && it.imageDesign)
? '<div class="ours" title="Our own design: '+esc(it.imageDesign)+'">◆ our lane: '+esc(it.imageDesign)+'</div>' : '';
c.innerHTML='<div class="swatch" style="position:relative;overflow:hidden;background:'+grad+'">'
+(hero?'<img class="hero" src="'+esc(hero)+'" alt="'+esc(it.title)+' — '+esc(it.marketplace)+'" loading="lazy"><div class="shimmer"></div>':(laneInset||pending))
+(it.isNew?'<div class="ribbon">NEW</div>':'')
+'<div class="cov '+it.ourCoverage+'">'+(it.ourCoverage==='gap'?'🎯 GAP':'✓ HAVE')+'</div>'
+(it.signalRank?'<div class="rank"'+(prov?' style="bottom:34px"':'')+'>▲ '+it.signalRank+'</div>':'')
+prov+oursInset+oursPill+'</div>'
+'<div class="body"><div class="title">'+esc(it.title)+'</div>'
+'<div class="company">by <b>'+esc(it.company)+'</b></div>'
+'<div class="chips"><span class="chip mk">'+esc(it.marketplace)+'</span><span class="chip">'+esc(it.style)+'</span><span class="chip">'+esc(it.color)+'</span><span class="chip">'+esc(it.priceBand)+'</span></div>'
+(it.spottedAt?'<div class="spotted" title="Spotted '+esc(it.spottedAt)+(it.realImageFetchedAt?' · real image fetched '+esc(it.realImageFetchedAt):'')+'">🕓 spotted '+esc(fmtSpotted(it.spottedAt))+(it.realImageFetchedAt?' · 📷 '+esc(fmtWhen(it.realImageFetchedAt)):'')+'</div>':'')
+'<div class="signal"><span class="dot">●</span> '+esc(it.signal)+'</div>'
+'<div class="tier">DW tier → '+esc(it.dwTier)+(it.url?' · <a class="src" href="'+esc(it.url)+'" target="_blank" rel="noopener noreferrer">source ↗</a>':'')+'</div>'
+(it.isOurOriginal?'<button class="shopbtn" data-id="'+esc(it.id)+'">+ Put on Shopify</button>':'')
+'</div>';
c.style.cursor="pointer"; c.title="Open detail + match against our catalog"; c.addEventListener("click",function(e){ if(!e.target.closest("a")&&!e.target.closest(".shopbtn")) openMatchup(it); });
var sb=c.querySelector(".shopbtn"); if(sb){ sb.addEventListener("click",function(e){ e.stopPropagation(); putOnShopify(sb); }); }
// hero load/error handling attached in JS (closure over `it` + `pending`) — shimmer clears on
// load; a failed REAL image falls back to our design; a failed our-design falls to the tile.
var im=c.querySelector(".swatch img.hero");
if(im){
var clear=function(sw){ var sh=sw&&sw.querySelector(".shimmer"); if(sh) sh.remove(); };
im.addEventListener("load",function(){ this.classList.add("ld"); clear(this.closest(".swatch")); });
im.addEventListener("error",function(){
var sw=this.closest(".swatch"); clear(sw);
if(it.realImage && this.getAttribute("src")===it.realImage && it.image){
var pv=sw.querySelector(".prov"); if(pv) pv.remove(); // no longer showing the real product
var ot=sw.querySelector(".oursthumb"); if(ot) ot.remove(); // hero IS ours now — no duplicate inset
this.setAttribute("src", it.image); return;
}
if(sw&&!sw.querySelector(".pending")) sw.insertAdjacentHTML("beforeend",pending);
this.remove();
});
if(im.complete&&im.naturalWidth>0){ im.classList.add("ld"); clear(c.querySelector(".swatch")); }
}
return c;
}
// a failed fetch used to leave state.loading=true forever — the board sat on "Loading…"
// with no way out. Every load path now surfaces an error bar with a Retry.
function showError(msg){ document.getElementById('errbar').innerHTML='<div class="errbar"><span>⚠ '+esc(msg)+'</span><button onclick="retryAll()">Retry</button></div>'; }
function clearError(){ document.getElementById('errbar').innerHTML=''; }
function retryAll(){ clearError(); loadFacets(); loadMeta(); refresh(); }
function clearAllFilters(){ state.filters={}; state.q=''; document.getElementById('search').value=''; save(); loadFacets(); chips(); refresh(); }
async function fetchPage(){
if(state.loading||state.done||state.view!=='grid') return;
state.loading=true; document.getElementById('loading').style.display='block';
try{
const r=await fetch('/api/items?'+qs({page:state.page,size:state.size}));
if(!r.ok) throw new Error('HTTP '+r.status);
const d=await r.json();
clearError();
document.getElementById('cnt').textContent=d.total;
document.getElementById('gapcnt').textContent=d.gapCount?('🎯 '+d.gapCount+' gaps'):'';
d.items.forEach(it=>document.getElementById('grid').appendChild(card(it)));
if(state.page===1&&d.total===0){
document.getElementById('grid').innerHTML='<div class="empty">No items match these filters — <a onclick="clearAllFilters()">clear all filters</a></div>';
state.done=true;
}
if(state.page*state.size>=d.total) state.done=true;
state.page++;
}catch(e){
showError('Couldn’t load items ('+e.message+'). The server may be restarting.');
}finally{
state.loading=false; document.getElementById('loading').style.display='none';
}
// If the loaded cards didn't fill the viewport, the IntersectionObserver won't
// re-fire (it only fires on an intersection *transition*) and the board stalls on
// page 1. Keep loading while the sentinel is still within (viewport + 400px).
if(!state.done){
const s=document.getElementById('sentinel'), r=s&&s.getBoundingClientRect();
if(r && r.top < (window.innerHeight||document.documentElement.clientHeight)+400) return fetchPage();
}
}
async function renderLanes(){
const d=await (await fetch('/api/lanes?'+qs())).json();
const max=Math.max(1,...d.lanes.map(l=>l.count));
let h='<table class="lanes"><thead><tr><th>Lane</th><th>Coverage</th><th>Avg signal</th><th>Volume</th><th>Marketplaces</th></tr></thead><tbody>';
for(const l of d.lanes){
h+='<tr><td><b>'+esc(l.lane)+'</b></td>'
+'<td><span class="pill '+l.coverage+'">'+(l.coverage==='gap'?'🎯 GAP':'✓ HAVE')+'</span></td>'
+'<td>▲ '+l.avgSignal+'</td>'
+'<td><span class="bars" style="width:'+(l.count/max*120)+'px"></span> '+l.count+'</td>'
+'<td style="color:var(--muted);font-size:12px">'+l.marketplaces.map(esc).join(', ')+'</td></tr>';
}
h+='</tbody></table><p style="color:var(--muted);font-size:12px;margin-top:12px">Our catalog: '+d.ourCatalog+' designs. Lanes marked 🎯 GAP are selling across marketplaces but thin/absent in our catalog — the whitespace.</p>';
document.getElementById('lanes').innerHTML=h;
}
function setView(v){ state.view=v; save();
document.getElementById('vGrid').className=v==='grid'?'on':''; document.getElementById('vLanes').className=v==='lanes'?'on':'';
document.getElementById('grid').style.display=v==='grid'?'grid':'none';
document.getElementById('lanes').style.display=v==='lanes'?'block':'none';
document.getElementById('sentinel').style.display=v==='grid'?'block':'none';
if(v==='lanes') renderLanes(); else refresh();
}
function refresh(){ if(state.view==='lanes'){ renderLanes(); return; } state.page=1; state.done=false; seenLane=new Set(); document.getElementById('grid').innerHTML=''; fetchPage(); }
const dens=document.getElementById('dens'); dens.value=localStorage.getItem('tdw_cols')||4;
document.documentElement.style.setProperty('--cols',dens.value);
dens.oninput=()=>{ document.documentElement.style.setProperty('--cols',dens.value); localStorage.setItem('tdw_cols',dens.value); };
const sortSel=document.getElementById('sort'); sortSel.value=state.sort; sortSel.onchange=()=>{ state.sort=sortSel.value; save(); refresh(); };
let t; document.getElementById('search').oninput=e=>{ clearTimeout(t); t=setTimeout(()=>{ state.q=e.target.value.trim(); chips(); refresh(); },250); };
document.getElementById('vGrid').onclick=()=>setView('grid'); document.getElementById('vLanes').onclick=()=>setView('lanes');
new IntersectionObserver(es=>{ if(es[0].isIntersecting) fetchPage(); },{rootMargin:'400px'}).observe(document.getElementById('sentinel'));
const _v=new URLSearchParams(location.search).get('view'); if(_v==='lanes'||_v==='grid') state.view=_v;
loadFacets(); chips(); setView(state.view); refreshQueueCount(); loadMeta();
/* ---- Put on Shopify: stage our-own design as a DW product (sequential DWPV SKU) ---- */
function putOnShopify(btn){
var id=btn.getAttribute("data-id"); btn.disabled=true; var old=btn.textContent; btn.textContent="Staging…";
fetch("/api/put-on-shopify?id="+encodeURIComponent(id)).then(function(r){return r.json();}).then(function(d){
if(d.sku){ btn.textContent=(d.already?"✓ "+d.sku+" (already staged)":"✓ "+d.sku+" staged"); btn.classList.add("done"); refreshQueueCount(); }
else { btn.textContent=d.error||"Failed"; btn.disabled=false; setTimeout(function(){btn.textContent=old;},2600); }
}).catch(function(){ btn.textContent="Error — retry"; btn.disabled=false; setTimeout(function(){btn.textContent=old;},2600); });
}
function refreshQueueCount(){ fetch("/api/shopify-queue").then(function(r){return r.json();}).then(function(d){ document.getElementById("qn").textContent=d.count; }).catch(function(){}); }
function openQueue(){
fetch("/api/shopify-queue").then(function(r){return r.json();}).then(function(d){
var m=document.getElementById("qmodal");
if(!m){ m=document.createElement("div"); m.id="qmodal"; m.style.cssText="position:fixed;inset:0;background:rgba(20,18,15,.6);display:none;align-items:center;justify-content:center;z-index:9999;padding:20px";
m.innerHTML='<div id="qbox" style="background:#fff;max-width:820px;width:100%;max-height:88vh;overflow:auto;border-radius:14px"></div>';
m.addEventListener("click",function(e){ if(e.target===m) m.style.display="none"; }); document.body.appendChild(m); }
var rows=(d.products||[]).map(function(p){
var when=p.stagedAt?new Date(p.stagedAt).toLocaleString(undefined,{year:"numeric",month:"short",day:"numeric",hour:"numeric",minute:"2-digit"}):"";
return '<tr style="border-top:1px solid #eee"><td style="padding:8px 10px">'
+(p.image?'<img src="'+p.image+'" style="width:46px;height:46px;object-fit:cover;border-radius:6px">':'')+'</td>'
+'<td style="padding:8px 10px;font-weight:600;color:#1f7a4d;white-space:nowrap">'+esc(p.sku)+'</td>'
+'<td style="padding:8px 10px">'+esc((p.title||"").slice(0,46))+'<div style="font-size:11px;color:#888">'+esc(p.style||"")+' · '+esc(p.color||"")+' · '+esc(p.priceTier||"")+'</div></td>'
+'<td style="padding:8px 10px;font-size:11px;color:#888;white-space:nowrap" title="'+esc(p.stagedAt||"")+'">🕓 '+esc(when)+'</td>'
+'<td style="padding:8px 10px;font-size:11px;color:#b8860b">'+esc(p.status||"staged")+'</td></tr>';
}).join("");
document.getElementById("qbox").innerHTML='<div style="padding:18px 22px;border-bottom:1px solid #eee;display:flex;align-items:center;gap:12px">'
+'<div style="font-size:17px;font-weight:600">🛍 Shopify staging queue</div>'
+'<div style="color:#888;font-size:13px">'+(d.count||0)+' × '+esc(d.series||"DWPV")+' — staged, awaiting the gated live push</div>'
+'<button onclick="document.getElementById(\'qmodal\').style.display=\'none\'" style="margin-left:auto;border:0;background:#f2efe9;border-radius:8px;padding:8px 12px;cursor:pointer">✕</button></div>'
+(d.count?'<table style="width:100%;border-collapse:collapse;font-size:13px"><thead><tr style="text-align:left;color:#999;font-size:11px"><th style="padding:6px 10px"></th><th style="padding:6px 10px">SKU</th><th style="padding:6px 10px">Design</th><th style="padding:6px 10px">Staged</th><th style="padding:6px 10px">Status</th></tr></thead><tbody>'+rows+'</tbody></table>'
:'<div style="padding:26px;color:#999">Nothing staged yet — click <b>+ Put on Shopify</b> on any card with an our-own design.</div>');
document.getElementById("qmodal").style.display="flex";
});
}
/* ---- trending → catalog matchup modal (internal "look at the item" + yesterday's find-similar) ---- */
function openMatchup(it){
let m=document.getElementById('tmodal');
if(!m){ m=document.createElement('div'); m.id='tmodal';
m.style.cssText='position:fixed;inset:0;background:rgba(20,18,15,.6);display:none;align-items:center;justify-content:center;z-index:9999;padding:20px';
m.innerHTML='<div id="tmbox" style="background:#fff;max-width:940px;width:100%;max-height:88vh;overflow:auto;border-radius:14px"></div>';
m.addEventListener('click',e=>{ if(e.target===m) m.style.display='none'; });
document.body.appendChild(m);
}
var q=(it.style+' '+(it.color==='Multi'?'':it.color)).trim();
var hero=it.realImage||it.image;
var heroImg=hero?'<img src="'+esc(hero)+'" alt="'+esc(it.title)+'" style="width:340px;max-width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:12px;display:block">':'';
var theirs=hero?'<div style="flex:0 0 auto">'+(it.realImageUrl?'<a href="'+esc(it.realImageUrl)+'" target="_blank" rel="noopener noreferrer" title="Open the product page">'+heroImg+'</a>':heroImg)
+(it.realImage?'<div style="font-size:10px;font-weight:800;letter-spacing:.1em;color:#555;margin-top:5px">THEIRS — '+esc(it.realImageSource||it.marketplace)+(it.realImageFetchedAt?' · <span title="'+esc(it.realImageFetchedAt)+'">🕓 '+esc(fmtWhen(it.realImageFetchedAt))+'</span>':'')+'</div>':'')+'</div>':'';
var ours=(it.realImage&&it.image&&it.isOurOriginal)
?'<div style="flex:0 0 auto;text-align:center"><img src="'+esc(it.image)+'" alt="Our design" style="width:150px;aspect-ratio:1;object-fit:cover;border-radius:10px;border:1px solid #eee;display:block">'
+'<div style="font-size:10px;font-weight:800;letter-spacing:.1em;color:#1f7a4d;margin-top:5px;max-width:150px">OURS'+(it.imageDesign?' — '+esc(it.imageDesign):'')+'</div></div>':'';
document.getElementById('tmbox').innerHTML='<div style="padding:20px 24px;border-bottom:1px solid #eee;display:flex;gap:18px;align-items:flex-start;flex-wrap:wrap">'
+theirs+ours
+'<div style="flex:1;min-width:230px"><div style="font-size:19px;font-weight:700;line-height:1.25">'+esc(it.title)+'</div>'
+'<div style="color:#555;font-size:13px;margin-top:5px">by <b>'+esc(it.company)+'</b> · '+esc(it.marketplace)+'</div>'
+'<div style="color:#777;font-size:12.5px;margin-top:6px">'+esc(it.style)+' · '+esc(it.color)+' · '+esc(it.priceBand)+(it.signalRank?' · ▲ '+it.signalRank:'')+'</div>'
+(it.signal?'<div style="font-size:12.5px;color:#2f5d3a;margin-top:7px">● '+esc(it.signal)+'</div>':'')
+'<div style="font-size:12px;margin-top:9px">'+(it.url?'<a href="'+esc(it.url)+'" target="_blank" rel="noopener noreferrer">trend source ↗</a>':'')
+(it.realImageUrl&&it.realImageUrl!==it.url?(it.url?' · ':'')+'<a href="'+esc(it.realImageUrl)+'" target="_blank" rel="noopener noreferrer">product page ↗</a>':'')+'</div>'
+(it.ourCoverage==='gap'&&it.genBrief?'<div style="font-size:12px;background:#fdf2f1;border:1px dashed #ecc8c4;color:#8f2b23;padding:9px 11px;border-radius:8px;margin-top:11px"><b>🎯 GAP brief:</b> '+esc(it.genBrief)+'</div>':'')
+'<div style="font-size:11px;color:#999;margin-top:10px">DW tier → '+esc(it.dwTier||'')+'</div></div>'
+'<button onclick="document.getElementById(\'tmodal\').style.display=\'none\'" style="margin-left:auto;border:0;background:#f2efe9;border-radius:8px;padding:8px 12px;cursor:pointer;font-size:15px">✕</button></div>'
+'<div style="padding:16px 24px"><div style="font-weight:600;margin-bottom:10px">🎯 Matched in our catalog <span id="tmc" style="color:#888;font-weight:400;font-size:13px"></span></div><div id="tmgrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px">Loading…</div></div>';
document.getElementById('tmodal').style.display='flex';
fetch('/api/matchup?q='+encodeURIComponent(q)).then(function(r){return r.json();}).then(function(d){
document.getElementById('tmc').textContent='('+d.matches.length+' of '+(d.total||0)+' shown)';
document.getElementById('tmgrid').innerHTML=(d.matches||[]).map(function(x){return '<a href="'+esc(x.url||'#')+'" target="_blank" rel="noopener noreferrer" style="text-decoration:none;color:inherit;border:1px solid #eee;border-radius:10px;overflow:hidden;display:block">'
+(x.image?'<img src="'+x.image+'" style="width:100%;height:120px;object-fit:cover">':'<div style="height:120px;background:#f2efe9"></div>')
+'<div style="padding:8px"><div style="font-size:12px;font-weight:600;line-height:1.3">'+esc((x.title||"").slice(0,52))+'</div><div style="font-size:11px;color:#888;margin-top:3px">'+esc(x.vendor||"")+'</div><div style="font-size:11px;color:#b8860b;margin-top:4px">View on DW ↗</div></div></a>';}).join('')||'<div style="color:#999">No catalog matches — a GAP worth generating.</div>';
}).catch(function(){document.getElementById('tmgrid').innerHTML='<div style="color:#c00">Matchup unavailable.</div>';});
}
</script>
</body>
</html>