[object Object]

← back to Japan Enrich

japan viewer: add distributor (buy-from channel) chip + facet + sort

b1fd6b03b5dd8738f378b61a5c0a4af6bb9ef330 · 2026-07-06 13:21:15 -0700 · Steve

Sangetsu is the mill but its SKUs are only orderable through the Goodrich
distributor (sangetsu-goodrich.co.th) — surface that channel separately from
the brand. Derived from source_url host in distInfo(); ~84% of japan-scope
SKUs (13,358) are Goodrich-distributed. Direct-mill lines (Lilycolor/Greenland)
render a muted 'direct' chip. Added: card chip, left-panel facet, Distributor
A→Z sort, info-drawer row, free-text search, and server-side facet/sort/filter parity.

Files touched

Diff

commit b1fd6b03b5dd8738f378b61a5c0a4af6bb9ef330
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jul 6 13:21:15 2026 -0700

    japan viewer: add distributor (buy-from channel) chip + facet + sort
    
    Sangetsu is the mill but its SKUs are only orderable through the Goodrich
    distributor (sangetsu-goodrich.co.th) — surface that channel separately from
    the brand. Derived from source_url host in distInfo(); ~84% of japan-scope
    SKUs (13,358) are Goodrich-distributed. Direct-mill lines (Lilycolor/Greenland)
    render a muted 'direct' chip. Added: card chip, left-panel facet, Distributor
    A→Z sort, info-drawer row, free-text search, and server-side facet/sort/filter parity.
---
 viewer-local/public/index.html | 15 +++++++++++----
 viewer-local/server.js         | 30 +++++++++++++++++++++++++++++-
 2 files changed, 40 insertions(+), 5 deletions(-)

diff --git a/viewer-local/public/index.html b/viewer-local/public/index.html
index 277820f..078b15c 100644
--- a/viewer-local/public/index.html
+++ b/viewer-local/public/index.html
@@ -84,6 +84,10 @@ body.imgonly .grid:not(.list) .card .b{display:none}
 .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)}
+.chip.dist.direct{border-color:var(--line);color:var(--mut)}
 .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" */
@@ -144,6 +148,7 @@ body.listmode #listhead{display:grid}
       <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>
@@ -188,7 +193,7 @@ function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;',
 // ── 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'],['collection','Collections'],['color_family','Colors'],['style','Styles'],
+ ['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'],
@@ -250,6 +255,7 @@ function card(p){
   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>`:'';
+  const dist=p.distributor?`<span class="chip dist${p.dist_direct?' direct':''}" title="${p.dist_direct?'Sold direct by the mill':'Purchase channel — this SKU can only be ordered from this distributor'}">${p.dist_direct?'':'🏬 '}${esc(p.distributor)}</span>`:'';
   return `<div class="card"${p.url?` data-href="${esc(p.url)}"`:''} data-sku="${esc(p.sku)}">
     ${img}<div class="b">
     <span class="vend">${src}${p.collection?(' · '+esc(p.collection)):''}</span>
@@ -258,7 +264,7 @@ function card(p){
     <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>`:''}
-      ${cwDots}${price}
+      ${dist}${cwDots}${price}
       <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>
@@ -269,7 +275,7 @@ function card(p){
     <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)}${kv('Collection',p.collection)}${kv('Pattern',p.title)}
+      ${kv('SKU',p.sku)}${kv('Source',p.src_label)}${kv('Distributor',p.distributor)}${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(', '))}
@@ -306,7 +312,7 @@ function applyFilters(){
   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].some(v=>v&&String(v).toLowerCase().includes(q)))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;};
@@ -318,6 +324,7 @@ function applyFilters(){
     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),
diff --git a/viewer-local/server.js b/viewer-local/server.js
index 1a3a71a..7b3916e 100644
--- a/viewer-local/server.js
+++ b/viewer-local/server.js
@@ -107,6 +107,27 @@ const fireClass = (s) => {
 };
 const collectionName = (c) => (c === 'ImportSelection' ? 'Import Selection' : c) || null;
 
+// ── distributor / purchase channel ──────────────────────────────────────────────
+// The MILL (source: Sangetsu / Lilycolor / Greenland) is NOT always who we order from.
+// Sangetsu is a mill whose catalog is only buyable through its regional DISTRIBUTOR,
+// Goodrich — the site we actually scraped (sangetsu-goodrich.co.th). So each row also
+// carries a buy-from channel, derived from source + source_url host, separate from the
+// brand badge. Lilycolor & Greenland sell direct (channel == mill) → dist_direct:true.
+const DIST_BY_HOST = [
+  [/sangetsu-goodrich\.co\.th$/i, 'Goodrich (Thailand)'],
+  [/sangetsu-goodrich\.vn$/i, 'Goodrich (Vietnam)'],
+  [/goodrichglobal\.com$/i, 'Goodrich (Singapore)'],
+];
+function distInfo(source, url) {
+  let host = '';
+  try { host = new URL(url).hostname.replace(/^www\./, ''); } catch {}
+  for (const [re, label] of DIST_BY_HOST) if (re.test(host)) return { distributor: label, dist_direct: false };
+  if (source === 'sangetsu') return { distributor: 'Goodrich', dist_direct: false };   // sangetsu w/o a recognized host is still via Goodrich
+  if (source === 'lilycolor') return { distributor: 'Lilycolor (direct)', dist_direct: true };
+  if (source === 'greenland') return { distributor: 'Greenland (direct)', dist_direct: true };
+  return { distributor: null, dist_direct: false };
+}
+
 // Flatten both vendors to one uniform SKU row: {source, sku, title, width, image, url, tags[]}
 function buildRows() {
   const rows = [];
@@ -150,6 +171,7 @@ function buildRows() {
       image_count: r.image_count || 0,
       image_kinds: r.image_kinds || [],
       url: r.shop_handle ? `https://shop.lilycolor.co.jp/products/${r.shop_handle}` : null,
+      ...distInfo('lilycolor', null),                     // facet/chip: buy-from channel (Lilycolor sells direct)
       is_sample: false,
       // colorway family — this card + its sibling colorways (vendor-style "other colors of this pattern")
       colorway_count: group.length,
@@ -189,6 +211,7 @@ function buildRows() {
         image: sImg,
         image_state: sImg ? 'Has swatch' : 'No image',    // facet: swatch availability
         url: r.source_url || null,
+        ...distInfo('sangetsu', r.source_url),             // facet/chip: buy-from channel (Sangetsu → Goodrich distributor)
         is_sample: false,
         colorway_count: cw.length,
         siblings,
@@ -232,6 +255,7 @@ function buildRows() {
         image: (greenSwatches.has(normSku(sku)) ? `/img/greenland/${sku}` : ((isCw ? imgs[sku] : r.cover_img) || r.cover_img)) || null,
         image_state: ((greenSwatches.has(normSku(sku))) || (isCw ? imgs[sku] : r.cover_img)) ? 'Has swatch' : 'No image',
         url: r.source_url || null,
+        ...distInfo('greenland', r.source_url),            // facet/chip: buy-from channel (Greenland direct)
         is_sample: false,
         colorway_count: isCw ? skus.length : 1,
         siblings,
@@ -273,6 +297,7 @@ const sorters = {
   color: (a, b) => { const r = (x) => { const i = FAMILY_ORDER.indexOf(x.color_family); return i < 0 ? 99 : i; }; return r(a) - r(b) || String(a.sku).localeCompare(String(b.sku)); },
   style: (a, b) => String(a.style || '~~').localeCompare(String(b.style || '~~')) || String(a.sku).localeCompare(String(b.sku)),
   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)),
 };
 
 // shared filter — opts.skip names a facet dimension to EXCLUDE (for drill-down facet counts)
@@ -283,7 +308,7 @@ function parseFilters(u) {
     q: (u.searchParams.get('q') || '').toLowerCase(),
     colors: csv('colors'), styles: csv('styles'), materials: csv('materials'),
     collections: csv('collections'), widths: csv('widths'), fires: csv('fires'),
-    funcs: csv('funcs'), prices: csv('prices'), images: csv('images'),
+    funcs: csv('funcs'), prices: csv('prices'), images: csv('images'), distributors: csv('distributors'),
     enriched: u.searchParams.get('enriched') || 'all',
   };
 }
@@ -300,6 +325,7 @@ function applyFilters(rows, f, opts = {}) {
   if (opts.skip !== 'funcs' && f.funcs.size) out = out.filter((r) => r.functions && r.functions.some((x) => f.funcs.has(x)));
   if (opts.skip !== 'price' && f.prices.size) out = out.filter((r) => r.price_band && f.prices.has(r.price_band));
   if (opts.skip !== 'image' && f.images.size) out = out.filter((r) => r.image_state && f.images.has(r.image_state));
+  if (opts.skip !== 'distributor' && f.distributors.size) out = out.filter((r) => r.distributor && f.distributors.has(r.distributor));
   if (opts.skip !== 'enriched' && f.enriched === 'enriched') out = out.filter((r) => r.enriched);
   if (opts.skip !== 'enriched' && f.enriched === 'unenriched') out = out.filter((r) => !r.enriched);
   return out;
@@ -368,6 +394,7 @@ const server = http.createServer((req, res) => {
       fire: r.fire, fire_class: r.fire_class, price_yen: r.price_yen, price_band: r.price_band,
       image: r.image, image_state: r.image_state, image_count: r.image_count || 0,
       url: r.url, colorway_count: r.colorway_count, siblings: (r.siblings || []).slice(0, SIB_MAX),
+      distributor: r.distributor, dist_direct: !!r.dist_direct,
       enriched: !!r.enriched, color_family: r.color_family, style: r.style, material: r.material,
       hex: r.hex, palette: r.palette || [], color_name: r.color_name || null,
     }));
@@ -418,6 +445,7 @@ const server = http.createServer((req, res) => {
       functions: tallyArr(applyFilters(POOL, f, { skip: 'funcs' }), 'functions'),
       price_band: tally(applyFilters(POOL, f, { skip: 'price' }), 'price_band'),
       image_state: tally(applyFilters(POOL, f, { skip: 'image' }), 'image_state'),
+      distributor: tally(applyFilters(POOL, f, { skip: 'distributor' }), 'distributor'),
       price_order: PRICE_ORDER,
       enriched: enrRows.filter((r) => r.enriched).length,
       unenriched: enrRows.filter((r) => !r.enriched).length,

← d13399a Retrofit japan/china staging viewer to internal-line-viewer  ·  back to Japan Enrich  ·  japan: persist distributor field into staging + dw_unified o 993b675 →