← back to Japan Enrich
viewer-local/public/index.html
632 lines
<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Japan · Sangetsu + Lilycolor · staged</title>
<style>
:root{--cols:6;--fs:1;--bg:#0c0d12;--panel:#15171f;--card:#12141b;--ink:#eef0f5;--mut:#9aa0b0;--acc:#5eead4;--line:#262a36;
--new:#f59e0b;--pub:#34d399}
*{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:9;background:linear-gradient(180deg,#0c0d12,#0c0d12e8);backdrop-filter:blur(8px);
border-bottom:1px solid var(--line);padding:10px 16px;display:flex;gap:12px;align-items:center;flex-wrap:wrap}
h1{font-size:15px;margin:0;font-weight:700;letter-spacing:.2px}
.pill{background:var(--new);color:#1a1205;font-weight:700;border-radius:999px;padding:2px 10px;font-size:12px}
.muted{color:var(--mut)}
select,input[type=text]{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:6px 9px;font-size:13px}
.ctl{display:flex;align-items:center;gap:7px}
input[type=range]{accent-color:var(--acc)}
.btn{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:6px 12px;cursor:pointer;font-size:13px;text-decoration:none}
.btn:hover{border-color:var(--acc)}
/* ── left panel: filters + field tables, ALL collapsed on load ── */
#layout{display:flex;align-items:flex-start}
aside{position:sticky;width:230px;min-width:230px;max-height:calc(100vh - 52px);overflow:auto;
padding:10px 10px 28px;border-right:1px solid var(--line);background:#0e0f15}
aside input[type=text]{width:100%;margin-bottom:7px;font-size:12.5px;padding:5px 8px}
aside details{border-bottom:1px solid var(--line)}
aside summary{cursor:pointer;list-style:none;display:flex;align-items:center;gap:6px;padding:8px 4px;
font-size:10.5px;font-weight:700;color:var(--mut);text-transform:uppercase;letter-spacing:.6px;user-select:none}
aside summary::-webkit-details-marker{display:none}
aside summary::before{content:'▸';font-size:9px;color:var(--mut);transition:transform .12s}
aside details[open] summary::before{transform:rotate(90deg)}
aside summary:hover{color:var(--ink)}
aside summary .cur{margin-left:auto;color:var(--acc);text-transform:none;letter-spacing:0;font-weight:600;
max-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fbody{padding:2px 0 8px}
.frow{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:3px 8px;border-radius:6px;
cursor:pointer;color:var(--mut);font-size:12.5px;user-select:none}
.frow:hover{background:var(--panel);color:var(--ink)}
.frow.active{background:var(--panel);color:var(--ink);outline:1px solid var(--acc);outline-offset:-1px}
.frow .fl{display:inline-flex;align-items:center;gap:7px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.frow .n{font-variant-numeric:tabular-nums;font-size:11px;color:var(--mut);flex:none}
.frow .dot{width:10px;height:10px;border-radius:50%;border:1px solid #0008;display:inline-block;flex:none}
.frow input[type=checkbox]{accent-color:var(--acc);margin:0 2px 0 0}
.fmore{padding:3px 8px;font-size:11px;color:var(--mut);font-style:italic}
#clearf{display:none;margin-top:10px;width:100%}
main{flex:1;min-width:0}
/* mobile: hamburger + full-screen filter drawer */
#burger{display:none;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:6px 12px;cursor:pointer;font-size:13px;font-weight:600;white-space:nowrap}
#fclose{display:none}
@media(max-width:880px){
#layout{display:block}
header{padding:8px 10px;gap:8px}
#burger{display:inline-flex;align-items:center;gap:6px}
.ctl:has(#density){display:none}
aside{display:none;position:fixed;inset:0;top:0!important;z-index:40;width:auto;min-width:0;max-height:none!important;
overflow:auto;border-right:0;padding:12px 12px 40px;background:var(--bg)}
aside.open-mobile{display:block}
#fclose{display:block;position:sticky;top:0;z-index:2;width:100%;background:var(--acc);color:#062a24;border:none;border-radius:8px;padding:11px;font-size:14px;font-weight:700;cursor:pointer;margin-bottom:10px}
.grid:not(.list){grid-template-columns:repeat(2,1fr)}
}
/* ── grid — tight, 1px stroke edges; text scales with density via --fs ── */
.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:8px;padding:10px 12px 60px}
.card{background:var(--card);border:1px solid var(--line);border-radius:2px;overflow:hidden;display:flex;flex-direction:column;position:relative;cursor:pointer}
.card:hover{border-color:#414a5e}
.imgwrap{position:relative;width:100%;aspect-ratio:1/1;background:#0a0b0f}
.imgwrap img{width:100%;height:100%;object-fit:cover;object-position:center;display:block}
.srcbadge{position:absolute;top:6px;left:6px;z-index:2;font-size:9px;font-weight:700;border-radius:4px;padding:1px 6px;letter-spacing:.3px;text-transform:uppercase;background:#000a;color:#cbd5e1}
.srcbadge.lilycolor{background:#7c5cffcc;color:#fff}
.srcbadge.sangetsu{background:#1f9e6bcc;color:#fff}
.srcbadge.greenland{background:#b0813bcc;color:#fff}
.imgwrap.noimg{background:repeating-linear-gradient(45deg,#0a0b0f,#0a0b0f 8px,#101218 8px,#101218 16px)}
.imgwrap.noimg::after{content:'🚫 no image';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#5b6373;font-size:11px;font-weight:600;letter-spacing:.3px}
.card .b{padding:calc(6px*var(--fs)) calc(8px*var(--fs)) calc(7px*var(--fs));display:flex;flex-direction:column;gap:calc(3px*var(--fs))}
.vend{font-size:calc(10px*var(--fs));color:var(--mut);font-weight:600;letter-spacing:.2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ttl{font-weight:600;font-size:calc(12.5px*var(--fs));line-height:1.3;letter-spacing:-.1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sku{font-family:ui-monospace,Menlo,monospace;font-size:calc(11px*var(--fs));color:var(--mut)}
/* field visibility toggles (grid view only — list view columns stay intact) */
body.f-off-vend .grid:not(.list) .vend{display:none}
body.f-off-ttl .grid:not(.list) .ttl{display:none}
body.f-off-sku .grid:not(.list) .card .b>.sku{display:none}
body.f-off-chips .grid:not(.list) .chips{display:none}
body.imgonly .grid:not(.list) .card .b{display:none}
/* ── chips (minimal) ── */
.chips{display:flex;flex-wrap:wrap;gap:5px;align-items:center}
.chip{font-size:calc(10px*var(--fs));font-weight:600;border-radius:999px;padding:1px calc(8px*var(--fs));letter-spacing:.2px;white-space:nowrap;border:1px solid transparent}
.chip.cl{border-color:transparent;color:var(--mut);background:transparent;display:inline-flex;align-items:center;gap:5px;padding-left:0}
.chip.cl .dot{width:calc(9px*var(--fs));height:calc(9px*var(--fs));border-radius:50%;border:1px solid #0008;display:inline-block}
.chip.price{color:var(--ink);background:transparent;border-color:var(--line)}
/* distributor / purchase-channel chip — accent when we must order from a distributor (Goodrich),
muted when the mill sells direct (Lilycolor/Greenland) */
.chip.dist{background:transparent;border-color:var(--acc);color:var(--acc);text-decoration:none;cursor:pointer}
.chip.dist:hover{background:#5eead41a}
.chip.dist.direct{border-color:var(--line);color:var(--mut)}
.chip.dist.direct:hover{border-color:var(--acc);color:var(--ink)}
/* import button — queue a SKU for onboarding (green when queued) */
.chip.imp{background:transparent;color:var(--mut);border-color:var(--line);cursor:pointer;user-select:none}
.chip.imp:hover{border-color:var(--acc);color:var(--ink)}
.chip.imp.on{border-color:var(--pub);color:var(--pub)}
/* ── visual match viewer (click item → our panel left, distributor matches right) ── */
#matchview{position:fixed;inset:0;z-index:70;background:#0a0b0f;display:none;grid-template-columns:minmax(280px,34%) 1fr;gap:0}
#matchview.open{display:grid}
#mv-left{border-right:1px solid var(--line);padding:18px;overflow:auto;display:flex;flex-direction:column;gap:12px;background:#0e0f15}
#mv-left img{width:100%;border-radius:4px;border:1px solid var(--line);background:#000}
#mv-left .mv-h{font-size:16px;font-weight:700}
#mv-left .mv-meta{font-size:12.5px;color:var(--mut);line-height:1.7}
#mv-left .mv-sw{display:inline-block;width:14px;height:14px;border-radius:3px;vertical-align:-2px;border:1px solid #0008;margin-right:5px}
#mv-right{padding:16px 18px 40px;overflow:auto}
#mv-right .mv-bar{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:12px}
#mv-right h2{font-size:15px;margin:0}
.mv-method{font-size:12px;border:1px solid var(--line);border-radius:6px;padding:5px 10px;color:var(--acc);text-decoration:none}
.mv-method:hover{background:var(--panel)}
#mv-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px}
.mv-card{background:var(--card);border:1px solid var(--line);border-radius:3px;overflow:hidden;cursor:pointer}
.mv-card:hover{border-color:var(--acc)}
.mv-card img{width:100%;aspect-ratio:1/1;object-fit:cover;display:block;background:#000}
.mv-card .mv-b{padding:6px 8px;font-size:11px}
.mv-card .mv-nm{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.mv-card .mv-cl{color:var(--mut);display:flex;align-items:center;gap:5px;margin-top:2px}
.mv-card .mv-dot{width:10px;height:10px;border-radius:2px;border:1px solid #0008}
#mv-close{position:absolute;top:12px;right:16px;z-index:2;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 13px;cursor:pointer;font-weight:700}
#mv-empty{color:var(--mut);padding:30px 4px;font-size:13px}
#qbadge{color:var(--pub);font-weight:600;cursor:pointer}
#qbadge:hover{text-decoration:underline}
/* ── import-queue drawer (right side) ── */
#qpanel{position:fixed;top:0;right:0;z-index:60;width:360px;max-width:92vw;height:100vh;background:#0e0f15;
border-left:1px solid var(--line);box-shadow:-8px 0 24px #0006;display:flex;flex-direction:column}
#qpanel[hidden]{display:none}
.qp-head{display:flex;align-items:center;gap:10px;padding:12px 14px;border-bottom:1px solid var(--line)}
.qp-head strong{font-size:14px;flex:1}
.qp-head button{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:5px 10px;cursor:pointer;font-size:12px}
.qp-head #qp-clear:hover{border-color:#c0392b;color:#e57373}
.qp-head #qp-close:hover{border-color:var(--acc)}
#qp-list{flex:1;overflow:auto;padding:8px 10px 20px}
.qi{display:grid;grid-template-columns:40px 1fr 22px;gap:9px;align-items:center;padding:6px 4px;border-bottom:1px solid var(--line)}
.qi img{width:40px;height:40px;object-fit:cover;border-radius:4px;background:#0a0b0f;display:block}
.qi .qm{min-width:0}
.qi .qs{font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--mut)}
.qi .qt{font-size:12.5px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.qi .qd{font-size:10px;color:var(--acc)}
.qi .qd.direct{color:var(--mut)}
.qi .qwhen{font-size:10px;color:var(--mut)}
.qi .qx{background:transparent;border:1px solid var(--line);color:var(--mut);border-radius:6px;cursor:pointer;width:22px;height:22px;line-height:1;padding:0}
.qi .qx:hover{border-color:#c0392b;color:#e57373}
.qp-empty{padding:40px 12px;text-align:center;color:var(--mut);font-size:12.5px}
.qp-foot{padding:10px 14px;border-top:1px solid var(--line);font-size:11px;line-height:1.5}
.chip.toggle{background:transparent;color:var(--mut);border-color:var(--line);cursor:pointer;user-select:none}
.chip.toggle:hover{border-color:var(--acc);color:var(--ink)}
/* colorway sibling dots — the "other colors of this pattern" */
.cws{display:inline-flex;gap:2px;align-items:center}
.cws i{width:calc(10px*var(--fs));height:calc(10px*var(--fs));border-radius:2px;display:inline-block;box-shadow:0 0 0 1px #fff2 inset;background:#3a3a40}
.cws i.pend{background:repeating-linear-gradient(45deg,#33333a,#33333a 2px,#26262b 2px,#26262b 4px)}
.cws .cwn{font-size:calc(9.5px*var(--fs));color:var(--mut);margin-left:2px}
/* ── collapsed detail drawer ── */
.info{display:none;flex-direction:column;gap:7px;border-top:1px dashed var(--line);padding:8px 8px 9px;cursor:default}
.card.info-open .info{display:flex}
.kv{display:grid;grid-template-columns:88px 1fr;gap:4px 10px;font-size:11.5px;align-items:baseline}
.kv .k{color:var(--mut)}
.kv .v{text-align:left;word-break:break-word;font-weight:500}
.lnk{color:var(--acc);text-decoration:none;border:1px solid var(--line);border-radius:6px;padding:2px 7px;font-size:11px}
.lnk:hover{background:var(--panel)}
.acts{display:flex;flex-wrap:wrap;gap:6px;margin-top:3px}
.act{font-size:11px;font-weight:600;border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:7px;padding:4px 10px;cursor:pointer}
.act:hover{border-color:var(--acc)}
#more{display:block;margin:8px auto 60px;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:9px 18px;cursor:pointer}
#empty{padding:60px;text-align:center;color:var(--mut)}
/* ── List view — aligned CSS-grid rows (.b display:contents → columns) ── */
:root{--listcols:42px 84px 105px minmax(140px,1.5fr) minmax(90px,1fr) 92px minmax(95px,1fr) minmax(95px,1fr) 78px 84px minmax(100px,1fr)}
.grid.list{grid-template-columns:1fr;gap:4px;padding:4px 12px 60px}
.grid.list .card{display:grid;align-items:center;border-radius:2px;grid-template-columns:var(--listcols);
column-gap:12px;min-height:52px;padding:0 12px 0 8px}
.grid.list .card>.b{display:contents}
.grid.list .imgwrap{width:42px;min-width:42px;height:42px;aspect-ratio:auto;border-radius:3px;overflow:hidden;margin:5px 0}
.grid.list .imgwrap.noimg::after{font-size:8px}
.grid.list .srcbadge{display:none}
.grid.list .vend{font-size:10.5px}
.grid.list .ttl{min-width:0;font-size:12.5px}
.grid.list .sku{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.grid.list .chips{display:none}
.grid.list .lc{display:block;font-size:11px;color:var(--mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.grid.list .info{grid-column:1 / -1;margin:2px 0 6px;padding:8px 4px 2px}
.lc{display:none}
/* sortable column header — same template as rows, sticky under the main header */
#listhead{display:none;position:sticky;z-index:8;background:#0e0f15;border-bottom:1px solid var(--line);
grid-template-columns:var(--listcols);column-gap:12px;padding:6px 24px 6px 20px;align-items:center}
body.listmode #listhead{display:grid}
#listhead .lh{font-size:10.5px;font-weight:700;color:var(--mut);text-transform:uppercase;letter-spacing:.4px;
cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;user-select:none}
#listhead .lh:hover{color:var(--ink)}
#listhead .lh.on{color:var(--acc)}
</style></head>
<body>
<header>
<h1 id="h1">Japan · Sangetsu + Lilycolor</h1>
<button id="burger" type="button" aria-label="Open filters">☰ Filters</button>
<span class="pill" id="total">…</span>
<span class="muted" id="meta"></span>
<div class="ctl"><label class="muted">Sort</label>
<select id="sort">
<option value="newest">Newest</option>
<option value="color">Color</option>
<option value="style">Style</option>
<option value="pattern">Pattern A→Z</option>
<option value="sku">SKU A→Z</option>
<option value="title">Title A→Z</option>
<option value="material">Material A→Z</option>
<option value="distributor">Distributor A→Z</option>
<option value="width">Width ↑</option>
<option value="price">Price ↑</option>
<option value="images">Most images</option>
</select></div>
<div class="ctl"><label class="muted">Density</label>
<input type="range" id="density" min="3" max="20" step="1"></div>
<button class="btn" id="view">▤ List view</button>
<button class="btn" id="impfiltered" title="Queue every SKU currently in view for import into dw_unified (promotion to Shopify stays gated)">⤓ Import filtered</button>
<span class="muted" id="qbadge"></span>
</header>
<div id="layout">
<aside aria-label="filters">
<button id="fclose" type="button">✕ Done — show products</button>
<input type="text" id="q" placeholder="Search sku / pattern / material…">
<input type="text" id="pattern" list="patternlist" placeholder="Pattern type-ahead…" title="type-ahead over every pattern in the staged catalog">
<datalist id="patternlist"></datalist>
<details id="fieldsbox"><summary>Card fields</summary>
<div class="fbody">
<label class="frow"><span class="fl"><input type="checkbox" data-fv="vend" checked>Source · collection</span></label>
<label class="frow"><span class="fl"><input type="checkbox" data-fv="ttl" checked>Pattern / title</span></label>
<label class="frow"><span class="fl"><input type="checkbox" data-fv="sku" checked>SKU</span></label>
<label class="frow"><span class="fl"><input type="checkbox" data-fv="chips" checked>Chips · details</span></label>
</div>
</details>
<div id="facetbox"><!-- one collapsed <details> table per data field --></div>
<button class="btn" id="clearf">✕ Clear filters</button>
</aside>
<main>
<div id="listhead" aria-label="sortable columns"></div>
<div class="grid" id="grid"></div>
<button id="more" style="display:none">Load more</button>
<div id="empty" style="display:none">No SKUs match.</div>
</main>
</div>
<div id="matchview" aria-label="visual match">
<button id="mv-close" type="button">✕ Close</button>
<div id="mv-left"></div>
<div id="mv-right"></div>
</div>
<div id="qpanel" hidden aria-label="import queue">
<div class="qp-head">
<strong>Import queue (<span id="qp-count">0</span>)</strong>
<button id="qp-clear" type="button">Clear all</button>
<button id="qp-close" type="button" aria-label="Close">✕</button>
</div>
<div id="qp-list"></div>
<div class="qp-foot muted">Queued SKUs stage into <code>dw_unified</code> via <code>db/promote-import-queue.js</code>. Promotion & Shopify publish are gated.</div>
</div>
<script>
const $=s=>document.querySelector(s);
const grid=$('#grid');
let ALL=[], VIEW=[], shown=0, FAMILY_ORDER=[], PRICE_ORDER=[], QUEUED=new Set();
const PAGE=120;
const FAMILY_DOT={'Red':'#c0392b','Orange/Brown':'#a8642a','Yellow':'#d4ac0d','Green':'#4e8b3b','Teal':'#1aa5a5',
'Blue':'#2e6fb0','Purple':'#7d5ba6','Pink':'#d87aa5','Neutral/Beige':'#cdbd96','Black':'#1a1a1a','Grey':'#9a9aa2','White':'#f2f2f2'};
const SRC_LABEL={lilycolor:'Lilycolor',sangetsu:'Sangetsu',greenland:'Greenland'};
function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
function fmtWhen(iso){try{return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});}catch(e){return String(iso||'');}}
// ── every data field is a left-panel table: collapsed on load, click value = filter ──
// 'functions' is an array field (multi-valued) — counted/filtered element-wise.
const FIELDS=[
['src_label','Source'],['distributor','Distributor'],['collection','Collections'],['color_family','Colors'],['style','Styles'],
['material','Material'],['width','Width'],['repeat','Repeat'],['fire_class','Fire class'],
['functions','Performance'],['price_band','Price ¥'],['image_state','Image'],['enr','Enriched'],
['prefix','SKU prefix'],['color_name','Color name'],
];
const ARR_FIELDS=new Set(['functions']);
const FIL={}; // field -> active value ('' = off)
FIELDS.forEach(([k])=>FIL[k]='');
let FCOUNTS={}; // field -> [[value,count]…] computed once from ALL
const FCAP=40; // rows shown per table (top-by-count)
// persisted prefs (CLAUDE.md: sort + density persist in localStorage) — 'japan.' namespace
$('#sort').value=localStorage.getItem('japan.sort')||'newest';
// ── density also drives type scale: fewer/larger cards → bigger type, 20/row → smallest ──
function setDensity(v){
v=Number(v);
const fs=Math.max(.5,Math.min(1.2, v<=6 ? 1+(6-v)*.06 : 1-(v-6)*.042));
document.documentElement.style.setProperty('--cols',v);
document.documentElement.style.setProperty('--fs',fs.toFixed(3));
}
const dens=localStorage.getItem('japan.density')||'6';
$('#density').value=dens; setDensity(dens);
$('#density').oninput=()=>{setDensity($('#density').value);localStorage.setItem('japan.density',$('#density').value);};
// ── mobile: hamburger opens the filter sidebar as a full-screen drawer ──
const asideEl=document.querySelector('aside');
$('#burger').onclick=()=>{asideEl.classList.add('open-mobile');document.body.style.overflow='hidden';};
$('#fclose').onclick=()=>{asideEl.classList.remove('open-mobile');document.body.style.overflow='';};
window.matchMedia('(max-width:880px)').addEventListener('change',e=>{
if(!e.matches){asideEl.classList.remove('open-mobile');document.body.style.overflow='';}
});
// ── card-field visibility toggles (image-only when all off) ──
const FVIS=JSON.parse(localStorage.getItem('japan.fieldvis')||'{"vend":true,"ttl":true,"sku":true,"chips":true}');
function applyFieldVis(){
for(const k of ['vend','ttl','sku','chips'])document.body.classList.toggle('f-off-'+k,!FVIS[k]);
document.body.classList.toggle('imgonly',!FVIS.vend&&!FVIS.ttl&&!FVIS.sku&&!FVIS.chips);
document.querySelectorAll('#fieldsbox input[data-fv]').forEach(cb=>cb.checked=!!FVIS[cb.dataset.fv]);
localStorage.setItem('japan.fieldvis',JSON.stringify(FVIS));
}
document.querySelectorAll('#fieldsbox input[data-fv]').forEach(cb=>{
cb.onchange=()=>{FVIS[cb.dataset.fv]=cb.checked;applyFieldVis();};
});
applyFieldVis();
// ── live JPY→USD rate (no key; fails soft to approx) ──
let FX={rate:0.0064,src:'approx'};
async function loadFX(){
try{
const d=await fetch('https://open.er-api.com/v6/latest/JPY').then(r=>r.json());
if(d&&d.result==='success'&&d.rates&&d.rates.USD)FX={rate:d.rates.USD,src:'live'};
}catch(e){}
}
function usd(yen){if(!yen||!FX.rate)return null;const v=yen*FX.rate;return '$'+(v>=100?Math.round(v).toLocaleString():v.toFixed(2));}
// NOTE: no <a> wrapper around the card — nesting anchors splits the DOM (adoption
// agency) and litters the grid with empty <a> cells. Navigation is delegated click.
function card(p){
const src=esc(p.src_label||'');
const img=p.image
?`<div class="imgwrap"><img loading="lazy" src="${esc(p.image)}" alt="${esc(p.title||p.sku)}" onerror="this.style.display='none';this.parentNode.classList.add('noimg')"><span class="srcbadge ${esc(p.source)}">${src}</span></div>`
:`<div class="imgwrap noimg"><span class="srcbadge ${esc(p.source)}">${src}</span></div>`;
const kv=(k,v)=>v?`<div class="kv"><span class="k">${k}</span><span class="v">${esc(v)}</span></div>`:'';
const dot=FAMILY_DOT[p.color_family]||'#666';
const cwDots=(p.siblings&&p.siblings.length)
?`<span class="cws" title="${p.colorway_count} colorways in this pattern">${p.siblings.map(s=>`<i class="${s.hex?'':'pend'}" style="${s.hex?('background:'+esc(s.hex)):''}" title="${esc(s.sku)}${s.family?(' · '+esc(s.family)):''}"></i>`).join('')}<span class="cwn">${p.colorway_count} cw</span></span>`:'';
const price=p.price_yen?`<span class="chip price" title="Japan LIST ¥${p.price_yen.toLocaleString()}/m — not net cost">${usd(p.price_yen)||('¥'+p.price_yen.toLocaleString())}<span class="muted">/m</span></span>`:'';
// distributor chip → links to the SKU's page on the distributor microsite (import from here)
const distLbl=`${p.dist_direct?'':'🏬 '}${esc(p.distributor||'')}`;
const dist=p.distributor
?(p.url
?`<a class="chip dist${p.dist_direct?' direct':''}" href="${esc(p.url)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()" title="Open on ${esc(p.distributor)} — import this SKU from here ↗">${distLbl} ↗</a>`
:`<span class="chip dist${p.dist_direct?' direct':''}" title="${p.dist_direct?'Sold direct by the mill':'Purchase channel — order from this distributor'}">${distLbl}</span>`)
:'';
return `<div class="card"${p.url?` data-href="${esc(p.url)}"`:''} data-sku="${esc(p.sku)}" data-dist="${esc(p.distributor||'')}">
${img}<div class="b">
<span class="vend">${src}${p.collection?(' · '+esc(p.collection)):''}</span>
<div class="ttl" title="${esc(p.title||'')}">${esc(p.title||p.sku||'(untitled)')}</div>
<span class="sku">${esc(p.sku||'')}</span>
<span class="lc" title="Collection">${esc(p.collection||'')}</span>
<div class="chips">
${p.color_family?`<span class="chip cl"><span class="dot" style="background:${dot}"></span>${esc(p.color_family)}</span>`:''}
${dist}${cwDots}${price}
<span class="chip imp${QUEUED.has(p.sku)?' on':''}" data-sku="${esc(p.sku)}" onclick="event.stopPropagation();toggleImport('${esc(p.sku)}',this)" title="Queue this SKU for import into dw_unified (promotion to Shopify is gated)">${QUEUED.has(p.sku)?'✓ queued':'⤓ import'}</span>
<span class="chip toggle" onclick="event.stopPropagation();this.closest('.card').classList.toggle('info-open')">ⓘ details</span>
</div>
<span class="lc" title="Color family">${esc(p.color_family||'')}</span>
<span class="lc" title="Style">${esc(p.style||'')}</span>
<span class="lc" title="Material">${esc(p.material||'')}</span>
<span class="lc" title="Width">${esc(p.width||'')}</span>
<span class="lc" title="Price ¥/m">${p.price_yen?('¥'+p.price_yen.toLocaleString()):''}</span>
<span class="lc" title="Fire class">${esc(p.fire_class||'')}</span>
</div>
<div class="info" onclick="event.stopPropagation()">
${kv('SKU',p.sku)}${kv('Source',p.src_label)}${p.distributor?`<div class="kv"><span class="k">Distributor</span><span class="v">${p.url?`<a class="lnk" href="${esc(p.url)}" target="_blank" rel="noopener noreferrer">${esc(p.distributor)} ↗</a>`:esc(p.distributor)}</span></div>`:''}${kv('Collection',p.collection)}${kv('Pattern',p.title)}
${kv('Color name',p.color_name)}${kv('Color family',p.color_family)}${kv('Style',p.style)}${kv('Material',p.material)}
${kv('Width',p.width)}${kv('Repeat',p.repeat)}${kv('Fire',p.fire)}
${kv('Performance',(p.functions||[]).join(', '))}
${kv('¥ list',p.price_yen?('¥'+p.price_yen.toLocaleString()+'/m'):'')}
${kv('Colorways',p.colorway_count>1?p.colorway_count:'')}
${kv('Enriched',p.enr)}
<div class="acts">
${p.sku?`<button class="act" data-copy="${esc(p.sku)}" onclick="copyTxt(this)" title="copy SKU">📋 SKU</button>`:''}
${p.url?`<a class="lnk" href="${esc(p.url)}" target="_blank" rel="noopener noreferrer">vendor page ↗</a>`:''}
</div>
</div>
</div>`;
}
// delegated card navigation — anything not interactive opens the vendor page (new tab)
grid.addEventListener('click',e=>{
if(e.target.closest('.chip.toggle,.chip.imp,.chip.dist,.info,a,button'))return;
const c=e.target.closest('.card[data-sku]');
if(c&&c.dataset.sku)openMatch(c.dataset.sku,c.dataset.dist||'');
});
async function openMatch(sku,dist){
const mv=$('#matchview'),L=$('#mv-left'),R=$('#mv-right');
L.innerHTML='<div class="mv-meta">Loading…</div>';R.innerHTML='';
mv.classList.add('open');document.body.style.overflow='hidden';
let d;try{d=await fetch(location.origin+'/api/match?sku='+encodeURIComponent(sku)+(dist?'&dist='+encodeURIComponent(dist):'')).then(r=>r.json());}
catch(e){R.innerHTML='<div id="mv-empty">match request failed</div>';return;}
if(d.error){L.innerHTML='<div class="mv-meta">'+esc(d.error)+'</div>';return;}
const it=d.item, meth=d.methods||{};
L.innerHTML=`<div class="mv-h">${esc(it.title||it.sku)}</div>${it.image?`<img src="${esc(it.image)}" alt="">`:''}
<div class="mv-meta"><div>SKU ${esc(it.sku)}</div><div>Distributor: <b>${esc(it.distributor||'—')}</b></div>
${it.hex?`<div><span class="mv-sw" style="background:${esc(it.hex)}"></span>${esc(it.hex)} · ${esc(it.color_family||'')}</div>`:''}
${it.url?`<div style="margin-top:4px"><a class="lnk" href="${esc(it.url)}" target="_blank" rel="noopener">source page ↗</a></div>`:''}</div>`;
const bar=`<div class="mv-bar"><h2>Matches${d.vendor?` on ${esc(it.distributor)} <span class="muted">(${d.catalog_size} in catalog)</span>`:''}</h2>
${meth.reverse_image?`<a class="mv-method" href="${esc(meth.reverse_image)}" target="_blank" rel="noopener">🔍 reverse-image</a>`:''}
${meth.source?`<a class="mv-method" href="${esc(meth.source)}" target="_blank" rel="noopener">🏬 source page</a>`:''}</div>`;
if(!d.vendor){R.innerHTML=bar+`<div id="mv-empty">No DW catalog for <b>${esc(it.distributor||'this distributor')}</b> — use reverse-image search or the source page to find it visually.</div>`;return;}
if(!d.matches.length){R.innerHTML=bar+`<div id="mv-empty">Color index still building for ${esc(it.distributor)} (or this item isn't color-enriched yet).</div>`;return;}
R.innerHTML=bar+'<div id="mv-grid">'+d.matches.map(m=>`<a class="mv-card" href="${esc(m.product_url||'#')}" target="_blank" rel="noopener" title="match score ${m.score} (lower=closer)">
<img loading="lazy" src="${esc(m.image)}" onerror="this.style.visibility='hidden'">
<div class="mv-b"><div class="mv-nm">${esc(m.name||m.sku)}</div>
<div class="mv-cl"><span class="mv-dot" style="background:${esc(m.hex)}"></span>${esc(m.color_name||m.family||'')}</div></div></a>`).join('')+'</div>';
}
window.openMatch=openMatch;
$('#mv-close').onclick=()=>{$('#matchview').classList.remove('open');document.body.style.overflow='';};
addEventListener('keydown',e=>{if(e.key==='Escape')$('#matchview').classList.remove('open');});
// ── import queue: enqueue INTENT for onboarding (promotion to dw_unified/Shopify is gated) ──
function updateQueueBadge(){const el=$('#qbadge');if(el)el.textContent=`⤓ ${QUEUED.size.toLocaleString()} queued`;}
// keep a grid card's import chip in sync when the queue changes from the panel
function syncCardImp(sku,queued){
const el=document.querySelector(`.chip.imp[data-sku="${(window.CSS&&CSS.escape)?CSS.escape(sku):sku}"]`);
if(el){el.classList.toggle('on',queued);el.textContent=queued?'✓ queued':'⤓ import';}
}
// ── import-queue drawer ──
const qpanel=()=>$('#qpanel');
async function openQueue(){
const d=await fetch(location.origin+'/api/import-queue').then(r=>r.json()).catch(()=>({items:[]}));
QUEUED=new Set(d.skus||[]); updateQueueBadge();
renderQueue(d.items||[]);
qpanel().hidden=false;
}
function renderQueue(items){
$('#qp-count').textContent=items.length.toLocaleString();
const list=$('#qp-list');
if(!items.length){list.innerHTML='<div class="qp-empty">Queue is empty.<br>Click <b>⤓ import</b> on any card to add SKUs.</div>';return;}
list.innerHTML=items.map(r=>{
const img=r.image?`<img loading="lazy" src="${esc(r.image)}" onerror="this.style.visibility='hidden'">`:'<img>';
const d=r.distributor?`<div class="qd${r.dist_direct?' direct':''}">${r.dist_direct?'':'🏬 '}${esc(r.distributor)}</div>`:'';
const when=r.queued_at?`<div class="qwhen" title="${esc(r.queued_at)}">🕓 ${esc(fmtWhen(r.queued_at))}</div>`:'';
return `<div class="qi" data-sku="${esc(r.sku)}">${img}
<div class="qm"><div class="qt" title="${esc(r.title||'')}">${esc(r.title||r.sku)}</div>
<div class="qs">${esc(r.sku)}</div>${d}${when}</div>
<button class="qx" title="Remove from queue" onclick="removeFromQueue('${esc(r.sku)}')">✕</button></div>`;
}).join('');
}
async function removeFromQueue(sku){
try{await fetch(location.origin+'/api/import/remove',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({sku})});}catch(e){}
QUEUED.delete(sku); updateQueueBadge(); syncCardImp(sku,false);
const row=$('#qp-list').querySelector(`.qi[data-sku="${(window.CSS&&CSS.escape)?CSS.escape(sku):sku}"]`);
if(row)row.remove();
$('#qp-count').textContent=QUEUED.size.toLocaleString();
if(!QUEUED.size)renderQueue([]);
}
window.removeFromQueue=removeFromQueue;
async function clearQueue(){
if(!QUEUED.size)return;
if(!confirm(`Clear all ${QUEUED.size.toLocaleString()} queued SKUs? (nothing is imported — this just empties the queue)`))return;
try{await fetch(location.origin+'/api/import/clear',{method:'POST'});}catch(e){}
const was=[...QUEUED]; QUEUED=new Set(); updateQueueBadge();
was.forEach(s=>syncCardImp(s,false)); renderQueue([]);
}
$('#qbadge').onclick=openQueue;
$('#qp-close').onclick=()=>{qpanel().hidden=true;};
$('#qp-clear').onclick=clearQueue;
async function toggleImport(sku,el){
const on=QUEUED.has(sku);
try{
const r=await fetch(location.origin+(on?'/api/import/remove':'/api/import'),
{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({sku})}).then(r=>r.json());
if(on)QUEUED.delete(sku); else QUEUED.add(sku);
if(el){el.classList.toggle('on',!on);el.textContent=on?'⤓ import':'✓ queued';}
updateQueueBadge();
if(!$('#qpanel').hidden)openQueue();
}catch(e){}
}
window.toggleImport=toggleImport;
async function importFiltered(){
const skus=VIEW.map(p=>p.sku);
if(!skus.length)return;
if(!confirm(`Queue ${skus.length.toLocaleString()} SKUs (current view) for import into dw_unified?\n\nThis only records intent — promotion to Shopify stays gated.`))return;
try{
await fetch(location.origin+'/api/import',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({skus})});
const q=await fetch(location.origin+'/api/import-queue').then(r=>r.json());
QUEUED=new Set(q.skus||[]); updateQueueBadge(); applyFilters();
if(!$('#qpanel').hidden)openQueue();
}catch(e){}
}
$('#impfiltered').onclick=importFiltered;
function copyTxt(btn){
const t=btn.dataset.copy||'';
navigator.clipboard.writeText(t).then(()=>{
const o=btn.textContent; btn.textContent='✓ copied';
setTimeout(()=>{btn.textContent=o;},1200);
});
}
window.copyTxt=copyTxt;
function matchField(p,k,v){
if(ARR_FIELDS.has(k))return (p[k]||[]).includes(v);
return String(p[k]??'')===v;
}
function applyFilters(){
const q=$('#q').value.trim().toLowerCase(), pat=$('#pattern').value.trim().toLowerCase();
VIEW=ALL.filter(p=>{
for(const [k] of FIELDS){ if(FIL[k]&&!matchField(p,k,FIL[k]))return false; }
if(pat&&!String(p.title||'').toLowerCase().includes(pat))return false;
if(q&&![p.title,p.sku,p.collection,p.style,p.material,p.color_family,p.color_name,p.distributor].some(v=>v&&String(v).toLowerCase().includes(q)))return false;
return true;
});
const famIdx=x=>{const i=FAMILY_ORDER.indexOf(x.color_family);return i<0?99:i;};
const s=$('#sort').value, cmp={
newest:null,
color:(a,b)=>famIdx(a)-famIdx(b)||String(a.hex||'').localeCompare(String(b.hex||''))||String(a.sku).localeCompare(String(b.sku)),
style:(a,b)=>String(a.style||'').localeCompare(String(b.style||''))||String(a.sku).localeCompare(String(b.sku)),
pattern:(a,b)=>String(a.title||'').localeCompare(String(b.title||''))||String(a.sku).localeCompare(String(b.sku)),
sku:(a,b)=>String(a.sku||'').localeCompare(String(b.sku||''),undefined,{numeric:true}),
title:(a,b)=>String(a.title||'').localeCompare(String(b.title||'')),
material:(a,b)=>String(a.material||'').localeCompare(String(b.material||''))||String(a.sku).localeCompare(String(b.sku)),
distributor:(a,b)=>String(a.distributor||'').localeCompare(String(b.distributor||''))||String(a.sku).localeCompare(String(b.sku)),
width:(a,b)=>(parseFloat(a.width)||1e9)-(parseFloat(b.width)||1e9),
price:(a,b)=>(a.price_yen||1e15)-(b.price_yen||1e15),
images:(a,b)=>(b.image_count||0)-(a.image_count||0),
}[s];
if(COLSORT.key){
const k=COLSORT.key, dir=COLSORT.dir;
VIEW=[...VIEW].sort((a,b)=>{
const av=a[k], bv=b[k];
if(av==null||av==='')return 1; if(bv==null||bv==='')return -1; // empties last either way
if(typeof av==='number'&&typeof bv==='number')return dir*(av-bv);
return dir*String(av).localeCompare(String(bv),undefined,{numeric:true});
});
}else if(cmp)VIEW=[...VIEW].sort(cmp);
shown=0; grid.innerHTML=''; renderMore();
$('#total').textContent=VIEW.length.toLocaleString()+' SKUs';
$('#empty').style.display=VIEW.length?'none':'block';
renderSide();
syncURL();
}
// ── URL <-> filter sync: EVERY left-panel facet is deep-linkable + shareable ──
// read on load (?distributor=Goodrich%20(Thailand) lands pre-filtered); write on
// every change (copy the address bar to share the exact filtered view).
function syncURL(){
const p=new URLSearchParams();
for(const [k] of FIELDS){if(FIL[k])p.set(k,FIL[k]);}
const q=$('#q').value.trim(); if(q)p.set('q',q);
const pat=$('#pattern').value.trim(); if(pat)p.set('pattern',pat);
const qs=p.toString();
history.replaceState(null,'',qs?('?'+qs):location.pathname);
}
function readURL(){
const p=new URLSearchParams(location.search);
for(const [k] of FIELDS){const v=p.get(k); if(v!=null)FIL[k]=v;}
const q=p.get('q'); if(q!=null)$('#q').value=q;
const pat=p.get('pattern'); if(pat!=null)$('#pattern').value=pat;
}
function renderMore(){
const slice=VIEW.slice(shown,shown+PAGE);
grid.insertAdjacentHTML('beforeend',slice.map(card).join(''));
shown+=slice.length;
$('#meta').textContent=`${shown.toLocaleString()} of ${VIEW.length.toLocaleString()} shown`;
$('#more').style.display=shown<VIEW.length?'block':'none';
}
function buildFacetCache(){
FCOUNTS={};
for(const [k] of FIELDS){
const m={};
if(ARR_FIELDS.has(k)){for(const p of ALL)for(const v of (p[k]||[]))m[v]=(m[v]||0)+1;}
else{for(const p of ALL){const v=p[k];if(v==null||v==='')continue;m[v]=(m[v]||0)+1;}}
let entries=Object.entries(m);
if(k==='color_family'&&FAMILY_ORDER.length)entries.sort((a,b)=>FAMILY_ORDER.indexOf(a[0])-FAMILY_ORDER.indexOf(b[0]));
else if(k==='price_band'&&PRICE_ORDER.length)entries.sort((a,b)=>PRICE_ORDER.indexOf(a[0])-PRICE_ORDER.indexOf(b[0]));
else entries.sort((a,b)=>b[1]-a[1]);
FCOUNTS[k]=entries;
}
// Pattern type-ahead over EVERY distinct pattern title
const seen=new Set(); const titles=[];
for(const p of ALL){const t=p.title;if(t&&!seen.has(t)){seen.add(t);titles.push(t);}}
titles.sort((a,b)=>String(a).localeCompare(String(b),undefined,{numeric:true}));
$('#patternlist').innerHTML=titles.map(s=>`<option value="${esc(s)}">`).join('');
}
// left panel — one collapsed <details> table per field; open state survives re-render
function renderSide(){
const openNow=new Set([...document.querySelectorAll('#facetbox details[open]')].map(d=>d.dataset.f));
$('#facetbox').innerHTML=FIELDS.map(([k,label])=>{
const all=FCOUNTS[k]||[];
if(!all.length)return ''; // field has no real values in this scope — skip the table
const rows=all.slice(0,FCAP).map(([v,n])=>{
const dot=k==='color_family'?`<span class="dot" style="background:${FAMILY_DOT[v]||'#666'}"></span>`:'';
return `<div class="frow${FIL[k]===String(v)?' active':''}" onclick="pickF('${k}',this.dataset.v)" data-v="${esc(v)}">
<span class="fl">${dot}${esc(v)}</span><span class="n">${n.toLocaleString()}</span></div>`;
}).join('');
const more=all.length>FCAP?`<div class="fmore">+ ${(all.length-FCAP).toLocaleString()} more — use search</div>`:'';
const cur=FIL[k]?`<span class="cur" title="${esc(FIL[k])}">${esc(FIL[k])}</span>`:'';
return `<details data-f="${k}"${openNow.has(k)?' open':''}><summary>${label}${cur}</summary><div class="fbody">${rows}${more}</div></details>`;
}).join('');
$('#clearf').style.display=(Object.values(FIL).some(Boolean)||$('#q').value||$('#pattern').value)?'block':'none';
}
function pickF(k,v){FIL[k]=(FIL[k]===v?'':v);applyFilters();}
window.pickF=pickF;
$('#clearf').onclick=()=>{FIELDS.forEach(([k])=>FIL[k]='');$('#q').value='';$('#pattern').value='';applyFilters();};
$('#sort').onchange=()=>{localStorage.setItem('japan.sort',$('#sort').value);COLSORT.key=null;renderListHead();applyFilters();};
let t; $('#q').oninput=()=>{clearTimeout(t);t=setTimeout(applyFilters,300);};
let tp; $('#pattern').oninput=()=>{clearTimeout(tp);tp=setTimeout(applyFilters,300);};
$('#more').onclick=renderMore;
// ── sortable column header (list view) ──
const LIST_COLS=[[null,''],['src_label','Source'],['sku','SKU'],['title','Pattern'],['collection','Collection'],
['color_family','Color'],['style','Style'],['material','Material'],['width','Width'],['price_yen','¥ list'],['fire_class','Fire']];
const COLSORT={key:null,dir:1};
function renderListHead(){
$('#listhead').innerHTML=LIST_COLS.map(([k,lbl])=>k
?`<span class="lh${COLSORT.key===k?' on':''}" onclick="colSort('${k}')">${lbl}${COLSORT.key===k?(COLSORT.dir>0?' ▲':' ▼'):''}</span>`
:'<span></span>').join('');
syncSticky();
}
function colSort(k){
if(COLSORT.key===k)COLSORT.dir=-COLSORT.dir; else{COLSORT.key=k;COLSORT.dir=1;}
renderListHead(); applyFilters();
}
window.colSort=colSort;
function syncSticky(){
const h=document.querySelector('header').offsetHeight;
$('#listhead').style.top=h+'px';
document.querySelector('aside').style.top=h+'px';
document.querySelector('aside').style.maxHeight=`calc(100vh - ${h}px)`;
}
addEventListener('resize',syncSticky);
function applyView(){const list=localStorage.getItem('japan.view')==='list';grid.classList.toggle('list',list);document.body.classList.toggle('listmode',list);$('#view').textContent=list?'▦ Grid view':'▤ List view';renderListHead();}
$('#view').onclick=()=>{localStorage.setItem('japan.view',localStorage.getItem('japan.view')==='list'?'grid':'list');applyView();};
applyView();
addEventListener('scroll',()=>{if(shown<VIEW.length&&innerHeight+scrollY>=document.body.offsetHeight-600)renderMore();});
async function loadAll(){
// resolve the host scope first (japan vs china) so H1/title match the served pool
try{
const sc=await fetch(location.origin+'/api/scope').then(r=>r.json());
if(sc&&sc.label){$('#h1').textContent=(sc.id==='japan'?'Japan · ':sc.id==='china'?'China · ':'')+sc.label;document.title=$('#h1').textContent+' · staged';}
}catch(e){}
const pd=await fetch(location.origin+'/api/products').then(r=>r.json());
FAMILY_ORDER=pd.family_order||[]; PRICE_ORDER=pd.price_order||[];
ALL=(pd.products||[]).map(p=>({...p,
src_label:SRC_LABEL[p.source]||p.source,
enr:p.enriched?'Enriched':'Unenriched',
}));
try{const q=await fetch(location.origin+'/api/import-queue').then(r=>r.json());QUEUED=new Set(q.skus||[]);}catch(e){}
updateQueueBadge();
buildFacetCache(); readURL(); applyFilters();
}
loadFX().finally(loadAll);
</script>
</body></html>