[object Object]

← back to Fabricut Landing

fix(fabricut): group grid by pattern — one card per pattern with colorway swatch strip (1325 cards -> 189)

c7514d0b3c2d26895768d87c8955895d9260a3a2 · 2026-08-24 14:01:09 -0700 · Steve

Root cause of perceived 'duplicates': the grid rendered one card per colorway/SKU,
so a 189-pattern catalog exploded into 1325 near-identical cards (94% of patterns
have >1 colorway). Data/API had 0 true duplicate SKUs. Fix is front-end only:
groupByPattern() collapses colorways of a series into one pattern card (swatch strip
+ N-colors), applied after filtering so facets/search re-group correctly. Verified
headless: 189 pattern cards, color filter 0-leak, infinite-scroll no dup.

Files touched

Diff

commit c7514d0b3c2d26895768d87c8955895d9260a3a2
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Aug 24 14:01:09 2026 -0700

    fix(fabricut): group grid by pattern — one card per pattern with colorway swatch strip (1325 cards -> 189)
    
    Root cause of perceived 'duplicates': the grid rendered one card per colorway/SKU,
    so a 189-pattern catalog exploded into 1325 near-identical cards (94% of patterns
    have >1 colorway). Data/API had 0 true duplicate SKUs. Fix is front-end only:
    groupByPattern() collapses colorways of a series into one pattern card (swatch strip
    + N-colors), applied after filtering so facets/search re-group correctly. Verified
    headless: 189 pattern cards, color filter 0-leak, infinite-scroll no dup.
---
 public/index.html | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++--
 public/styles.css | 18 ++++++++++++
 2 files changed, 100 insertions(+), 3 deletions(-)

diff --git a/public/index.html b/public/index.html
index c6589d1..9d1e5fe 100644
--- a/public/index.html
+++ b/public/index.html
@@ -246,8 +246,84 @@ document.querySelectorAll('#fieldsbox input[data-fv]').forEach(cb=>{
   cb.onchange=()=>{FVIS[cb.dataset.fv]=cb.checked;applyFieldVis();};
 });
 applyFieldVis();
+// ── group colorways into one card per PATTERN (series) ──
+// Each group mirrors a representative colorway's sortable fields (hue/series/sku/book…)
+// so the existing sort + column-sort machinery keeps working on grouped rows.
+function groupByPattern(list){
+  const m=new Map();
+  for(const p of list){
+    const key=String(p.series||p.display_name||'').trim().toLowerCase()||('__'+p.handle);
+    let g=m.get(key);
+    if(!g){ g={items:[],books:new Set(),colors:new Map(),rep:p}; m.set(key,g); }
+    g.items.push(p);
+    if(p.book)g.books.add(p.book);
+    const ck=String(p.color||p.display_name||'').trim().toLowerCase();
+    if(ck&&!g.colors.has(ck))g.colors.set(ck,p);   // one rep product per distinct colour (swatch strip)
+  }
+  const out=[];
+  for(const g of m.values()){
+    const rep=g.rep, colorways=[...g.colors.values()];
+    const hues=g.items.map(p=>p.hue).filter(h=>h!=null);
+    out.push({
+      __group:true,
+      series:rep.series||rep.display_name||'', colorways, nColors:g.colors.size, nItems:g.items.length,
+      books:[...g.books],
+      // representative / sortable fields (mirror a product so cmp + colsort work unchanged)
+      book:[...g.books][0]||rep.book||'Fabricut', display_name:rep.series||rep.display_name||'',
+      color:g.colors.size+' color'+(g.colors.size===1?'':'s'), color_bucket:rep.color_bucket,
+      hue:hues.length?Math.min(...hues):999, val:rep.val, style:rep.style,
+      sku:rep.sku, dw_sku:rep.dw_sku, handle:rep.handle, swatch:rep.swatch, room:rep.room,
+      material:rep.material, width:rep.width, length:rep.length, repeat:rep.repeat, match:rep.match,
+    });
+  }
+  return out;
+}
+// one card per pattern: hero swatch + book(s) + pattern name + a colorway swatch strip.
+// Main click opens the representative colourway's PDP; each swatch/detail row opens its own PDP.
+function patternCard(g){
+  const title=esc(g.series||g.display_name||'(untitled)');
+  const hasRoom=g.room&&g.room!==g.swatch;
+  const img=g.swatch
+    ?`<div class="imgwrap"><img loading="lazy" src="${esc(g.swatch)}" alt="${title}" onerror="this.style.display='none';this.parentNode.classList.add('noimg')">`+
+      (hasRoom?`<img loading="lazy" class="roomimg" src="${esc(g.room)}" alt=""><span class="roombadge">ROOM</span>`:'')+`</div>`
+    :`<div class="imgwrap noimg"></div>`;
+  const dot=BUCKET_HEX[g.color_bucket]||'#666';
+  const books=g.books||[];
+  const bookLabel=drill('book',books[0]||'Fabricut',books[0]||'Fabricut')+(books.length>1?` <span class="bmore" title="${esc(books.join(', '))}">+${books.length-1}</span>`:'');
+  const SWMAX=16;
+  const sw=(g.colorways||[]).slice(0,SWMAX).map(c=>
+    `<a class="sw${c.swatch?'':' noimg'}" href="/product/${encodeURIComponent(c.handle)}" title="${esc(c.color||c.display_name||'')}">`+
+      (c.swatch?`<img loading="lazy" src="${esc(c.swatch)}" alt="${esc(c.color||'')}" onerror="this.parentNode.classList.add('noimg');this.remove()">`:'')+`</a>`
+  ).join('');
+  const swMore=(g.colorways||[]).length>SWMAX?`<span class="swmore">+${g.colorways.length-SWMAX}</span>`:'';
+  const nColors=g.nColors||(g.colorways||[]).length;
+  return `<div class="card patcard" data-href="/product/${encodeURIComponent(g.handle)}" data-handle="${esc(g.handle)}" data-sku="${esc(g.dw_sku||'')}" data-mfr="${esc(g.sku||'')}" data-title="${esc(g.series||'')}">
+    ${img}<div class="b">
+    <span class="vend">${bookLabel}</span>
+    <div class="ttl">${title}</div>
+    <span class="lc" title="Colorways">${nColors} color${nColors===1?'':'s'}</span>
+    <div class="swatches">${sw}${swMore}</div>
+    <div class="chips">
+      ${g.color_bucket?`<span class="chip cl"><span class="dot" style="background:${dot}"></span>${drill('color_bucket',g.color_bucket,g.color_bucket)}</span>`:''}
+      <span class="chip toggle" onclick="event.stopPropagation();this.closest('.card').classList.toggle('info-open')">ⓘ ${nColors} color${nColors===1?'':'s'}</span>
+    </div>
+    <span class="lsku" title="Vendor SKU">${esc(g.sku||'')}</span>
+    <span class="ldw" title="DW SKU">${esc(g.dw_sku||'')}</span>
+    <span class="lc" title="Material">${drill('material',g.material,g.material)}</span>
+    <span class="lc" title="Width">${drill('width',g.width,g.width)}</span>
+    <span class="lc" title="Length">${drill('length',g.length,g.length)}</span>
+    <span class="lc" title="Repeat">${drill('repeat',g.repeat,g.repeat)}</span>
+    <span class="lc" title="Match">${drill('match',g.match,g.match)}</span>
+    </div>
+    <div class="info" onclick="event.stopPropagation()">
+      ${(g.colorways||[]).map(c=>`<div class="kv"><span class="k">${esc(c.color||c.display_name||'(colour)')}</span><span class="v"><a class="cwlink" href="/product/${encodeURIComponent(c.handle)}">${esc(c.dw_sku||c.sku||'open')} ↗</a></span></div>`).join('')}
+    </div>
+  </div>`;
+}
 // 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.
+// card(p) renders a single colourway — retained for reference; the grid now renders
+// patternCard(g). Kept intentionally so a per-colourway view can be re-enabled cheaply.
 function card(p){
   const title=esc(p.display_name||p.series||p.sku||'(untitled)')+(p.color&&p.color!==p.display_name?(', '+esc(p.color)):'');
   const hasRoom=p.room&&p.room!==p.swatch;
@@ -337,12 +413,15 @@ function toast(msg,ok){
 }
 function applyFilters(nav){
   const q=$('#q').value.trim().toLowerCase(), pat=$('#pattern').value.trim().toLowerCase();
-  VIEW=ALL.filter(p=>{
+  const matched=ALL.filter(p=>{
     for(const [k] of FIELDS){ if(FIL[k]&&String(p[k]??'')!==FIL[k])return false; }
     if(pat&&!String(p.series||p.display_name||'').toLowerCase().includes(pat))return false;
     if(q&&![p.display_name,p.series,p.sku,p.dw_sku,p.color,p.book,p.japan_sku,p.japan_vendor].some(v=>v&&String(v).toLowerCase().includes(q)))return false;
     return true;
   });
+  // Group colorways into one card per PATTERN (series). Filtering happens first (above),
+  // so pattern cards only carry colorways that match the active filters/search.
+  VIEW=groupByPattern(matched);
   const s=$('#sort').value, cmp={
     newest:null,
     color:(a,b)=>(a.hue??999)-(b.hue??999)||(a.val??0)-(b.val??0),
@@ -361,14 +440,14 @@ function applyFilters(nav){
     });
   }else if(cmp)VIEW=[...VIEW].sort(cmp);
   shown=0; grid.innerHTML=''; renderMore();
-  $('#total').textContent=VIEW.length.toLocaleString()+' designs';
+  $('#total').textContent=VIEW.length.toLocaleString()+' patterns';
   $('#empty').style.display=VIEW.length?'none':'block';
   renderSide();
   if(nav!=='none')writeURL(nav==='push');   // reflect filter state into the URL (shareable/deep-linkable)
 }
 function renderMore(){
   const slice=VIEW.slice(shown,shown+PAGE);
-  grid.insertAdjacentHTML('beforeend',slice.map(card).join(''));
+  grid.insertAdjacentHTML('beforeend',slice.map(patternCard).join(''));
   shown+=slice.length;
   $('#meta').textContent=`${shown.toLocaleString()} of ${VIEW.length.toLocaleString()} shown`;
   $('#more').style.display=shown<VIEW.length?'block':'none';
diff --git a/public/styles.css b/public/styles.css
index 6abbe3d..1c91c59 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -287,3 +287,21 @@ body[data-line="fallingstar"] .books h2::after{content:"";display:block;width:72
 .preview .pv-body{margin-top:10px;border-top:1px solid var(--line);padding-top:8px;color:var(--ink)}
 .preview .pv-body p{margin:0 0 8px}
 .preview .pv-warn{color:#8a5a00}
+
+/* ── pattern-grouped cards: colourway swatch strip (added 2026-08-24: dedupe by pattern) ── */
+.card.patcard .swatches{display:flex;flex-wrap:wrap;gap:3px;margin:5px 0 3px}
+.card.patcard .sw{width:22px;height:22px;border-radius:3px;overflow:hidden;display:block;
+  border:1px solid rgba(0,0,0,.14);background:#eee;flex:0 0 auto}
+.card.patcard .sw img{width:100%;height:100%;object-fit:cover;display:block}
+.card.patcard .sw.noimg{background:repeating-linear-gradient(45deg,#eee,#eee 4px,#e2e2e2 4px,#e2e2e2 8px)}
+.card.patcard .sw:hover{border-color:rgba(0,0,0,.4)}
+.card.patcard .swmore{font-size:.72em;opacity:.6;align-self:center;padding-left:3px;white-space:nowrap}
+.bmore{font-size:.72em;opacity:.55;font-weight:600}
+/* colourway list inside the ⓘ details drawer */
+.card.patcard .info .cwlink{text-decoration:none;opacity:.85}
+.card.patcard .info .cwlink:hover{text-decoration:underline;opacity:1}
+/* when card-field chips are toggled off, hide the swatch strip too (it's a colour aid) */
+body.f-off-chips .card.patcard .swatches{display:none}
+body.imgonly .card.patcard .swatches{display:none}
+/* list view: keep the swatch strip compact on one line */
+#grid.list .card.patcard .swatches{max-width:220px;overflow:hidden;flex-wrap:nowrap}

← aa2887a chore: v1.0.0 — go-live (session close)  ·  back to Fabricut Landing  ·  chore(fabricut): rebase onto live — add GA snippet + pin pro bdb71d3 →