← back to All Designerwallcoverings
viewer control standard: add Fields on/off menu to index.html (+Image toggle, mfr sorters) and list-toggle+Fields menu to vendors.html
7f2ae6c7e52339562bca706c1cc0e23dd1b63532 · 2026-08-11 14:21:21 -0700 · Steve
Files touched
M public/index.htmlM public/vendors.htmlM server.js
Diff
commit 7f2ae6c7e52339562bca706c1cc0e23dd1b63532
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Aug 11 14:21:21 2026 -0700
viewer control standard: add Fields on/off menu to index.html (+Image toggle, mfr sorters) and list-toggle+Fields menu to vendors.html
---
public/index.html | 62 +++++++++++++++++++++-----
public/vendors.html | 124 +++++++++++++++++++++++++++++++++++++++++++++++-----
server.js | 3 ++
3 files changed, 165 insertions(+), 24 deletions(-)
diff --git a/public/index.html b/public/index.html
index 13f0c7c..95d9c36 100644
--- a/public/index.html
+++ b/public/index.html
@@ -230,8 +230,23 @@
td.acts-td .intpanel { width:auto; margin-top:5px; }
/* CARD FIELDS visibility toggles — all off = pure image-only texture wall */
.grid.h-sku .sku, .grid.h-ttl .ttl, .grid.h-vt .vt, .grid.h-mfrn .mfrn, .grid.h-mfrname .mfrname, .grid.h-fac .fac, .grid.h-price .price, .grid.h-when .when, .grid.h-act .acts { display:none; }
+ .grid.h-img .thumb { display:none; }
.grid.h-sku.h-ttl.h-vt.h-mfrn.h-mfrname.h-fac.h-price.h-when .meta { display:none; }
+ /* Fields ▾ control-bar menu — a second surface onto the SAME hiddenFields state as the
+ rail "Card fields" section (one source of truth; both stay in sync). */
+ .fieldmenu { position:relative; }
+ .fbtn { background:var(--card); color:var(--mut); border:1px solid var(--line); border-radius:8px;
+ padding:7px 10px; font-size:13px; cursor:pointer; font-family:inherit; }
+ .fbtn:hover { border-color:var(--accent); color:var(--txt); }
+ .fpop { position:absolute; top:calc(100% + 6px); right:0; background:#0f0f13; border:1px solid var(--line);
+ border-radius:8px; box-shadow:0 14px 34px rgba(0,0,0,.5); padding:8px 4px; z-index:40; min-width:210px; }
+ .fpop[hidden] { display:none; }
+ .fpop label { display:flex; align-items:center; gap:9px; padding:6px 12px; font-size:12.5px; color:var(--txt);
+ cursor:pointer; white-space:nowrap; }
+ .fpop label:hover { background:var(--card); }
+ .fpop input { accent-color: var(--accent); }
+
/* ── list view ── */
.listwrap { padding:0 16px 30px; overflow-x:auto; }
table { width:100%; border-collapse:collapse; font-size:12.5px; }
@@ -273,6 +288,8 @@
<option value="type">Type</option>
<option value="series">Book / Series</option>
<option value="sku">SKU</option>
+ <option value="mfr">Mfr #</option>
+ <option value="mfrname">Mfr Name</option>
<option value="pattern">Pattern</option>
<option value="color">Color (family)</option>
<option value="style">Style</option>
@@ -284,6 +301,7 @@
</span>
<span class="viewseg"><button id="vGrid">Grid</button><button id="vList">List</button></span>
<span class="bar" id="densWrap">Density <input type="range" id="dens" min="2" max="20" value="6"> <span id="densN"></span></span>
+ <span class="fieldmenu"><button id="fieldsBtn" class="fbtn" aria-haspopup="true" aria-expanded="false">Fields ▾</button><div class="fpop" id="fpop" hidden role="menu"></div></span>
<span class="bar" id="fmBar" title="FileMaker Pro source-of-truth">
<span id="fmStat" class="fmstat">FM …</span>
<button id="fmMismatch" class="fmtoggle" type="button" title="Show only rows where FileMaker and Shopify mfr numbers disagree">⚠ Mismatches</button>
@@ -492,8 +510,16 @@ document.querySelectorAll('details.sec').forEach((d) => {
});
/* CARD FIELDS: toggle which card elements render — all off = image-only texture wall */
-const FIELD_DEFS = [['sku', 'SKU'], ['ttl', 'Title'], ['vt', 'Vendor · Type'], ['mfrn', 'Mfr #'], ['mfrname', 'Mfr Name'], ['fac', 'Color / Style / Material'], ['price', 'Price'], ['when', 'Active / Staged date'], ['act', 'Website / Internal buttons']];
+const FIELD_DEFS = [['img', 'Image'], ['sku', 'SKU'], ['ttl', 'Title'], ['vt', 'Vendor · Type'], ['mfrn', 'Mfr #'], ['mfrname', 'Mfr Name'], ['fac', 'Color / Style / Material'], ['price', 'Price'], ['when', 'Active / Staged date'], ['act', 'Website / Internal buttons']];
let hiddenFields = loadSet('all_hidden_fields');
+// One field-toggle STATE (hiddenFields) drives TWO surfaces: the left-rail "Card fields"
+// section AND the control-bar "Fields ▾" popover. toggleField() flips the shared set and
+// re-renders both so they never drift.
+function toggleField(k) {
+ hiddenFields.has(k) ? hiddenFields.delete(k) : hiddenFields.add(k);
+ localStorage.setItem('all_hidden_fields', JSON.stringify([...hiddenFields]));
+ applyFieldClasses(); drawFieldToggles(); drawFieldMenu();
+}
function drawFieldToggles() {
const row = document.getElementById('fieldsRow'); row.innerHTML = '';
FIELD_DEFS.forEach(([k, label]) => {
@@ -501,20 +527,32 @@ function drawFieldToggles() {
const el = document.createElement('div');
el.className = 'fitem' + (on ? ' on' : '');
el.innerHTML = `<span class="box">${on ? '✓' : ''}</span><span class="lbl">${label}</span>`;
- el.onclick = () => {
- hiddenFields.has(k) ? hiddenFields.delete(k) : hiddenFields.add(k);
- localStorage.setItem('all_hidden_fields', JSON.stringify([...hiddenFields]));
- applyFieldClasses(); drawFieldToggles();
- };
+ el.onclick = () => toggleField(k);
row.appendChild(el);
});
document.getElementById('nFields').textContent = hiddenFields.size ? `${FIELD_DEFS.length - hiddenFields.size}/${FIELD_DEFS.length}` : '';
document.getElementById('nFields').style.display = hiddenFields.size ? '' : 'none';
}
+// Control-bar "Fields ▾" popover — a checkbox per card field, same state as the rail.
+function drawFieldMenu() {
+ const pop = document.getElementById('fpop'); if (!pop) return;
+ pop.innerHTML = FIELD_DEFS.map(([k, label]) =>
+ `<label><input type="checkbox" data-field="${k}"${hiddenFields.has(k) ? '' : ' checked'}> ${label}</label>`).join('');
+ pop.querySelectorAll('input').forEach((cb) => cb.addEventListener('change', () => toggleField(cb.dataset.field)));
+}
function applyFieldClasses() {
FIELD_DEFS.forEach(([k]) => grid.classList.toggle('h-' + k, hiddenFields.has(k)));
}
-applyFieldClasses(); drawFieldToggles();
+applyFieldClasses(); drawFieldToggles(); drawFieldMenu();
+
+// Fields ▾ open/close (click-away closes).
+const fieldsBtn = document.getElementById('fieldsBtn'), fpop = document.getElementById('fpop');
+if (fieldsBtn && fpop) {
+ fieldsBtn.addEventListener('click', (e) => { e.stopPropagation();
+ const open = fpop.hidden; fpop.hidden = !open; fieldsBtn.setAttribute('aria-expanded', String(open)); });
+ document.addEventListener('click', (e) => { if (!fpop.hidden && !e.target.closest('.fieldmenu')) {
+ fpop.hidden = true; fieldsBtn.setAttribute('aria-expanded', 'false'); } });
+}
const sortSel = document.getElementById('sort'), dirBtn = document.getElementById('dir');
sortSel.value = ['price_asc','price_desc'].includes(state.sort) ? 'price' : state.sort;
@@ -1149,15 +1187,15 @@ function card(r) {
const gcount = (r.image_count || (r.images ? r.images.length : (r.image ? 1 : 0)));
const galBadge = gcount > 1 ? `<span class="imgcount" title="${gcount} images — hover to cycle">⧉ ${gcount}</span>` : '';
const thumb = r.image
- ? `<div class="thumb" style="background-image:url('${r.image}')">${stBadge}${discoBadge}${galBadge}</div>`
- : `<div class="thumb empty">${stBadge}${discoBadge}no image</div>`;
+ ? `<div class="thumb" data-f="img" style="background-image:url('${r.image}')">${stBadge}${discoBadge}${galBadge}</div>`
+ : `<div class="thumb empty" data-f="img">${stBadge}${discoBadge}no image</div>`;
// Card data atoms → drill spans (href-to-deeper-data). The card itself is a click-to-open
// element, so in-card atoms are <span class="drill"> (asSpan=true), NEVER nested <a>; a
// delegated .meta handler filters + stopPropagation so the card's window.open never fires.
const vt = [r.vendor && drill('vendor', r.vendor, r.vendor, true), r.type && drill('type', r.type, r.type, true)]
.filter(Boolean).join(' · ');
const mfrn = mfrHTML(r);
- const mfrname = r.mfr_name ? `<div class="mfrname"><b>Mfr</b> ${esc(r.mfr_name)}</div>` : '';
+ const mfrname = r.mfr_name ? `<div class="mfrname" data-f="mfrname"><b>Mfr</b> ${esc(r.mfr_name)}</div>` : '';
const fmspec = fmSpecsHTML(r);
const facAtoms = [
...(r.colors || []).map((c) => drill('color', c, c, true)),
@@ -1165,8 +1203,8 @@ function card(r) {
...(r.materials || []).map((c) => drill('material', c, c, true)),
].filter(Boolean).slice(0, 4);
const fac = facAtoms.join(' · ');
- const price = r.price ? `<span class="price">$${r.price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>` : '';
- el.innerHTML = `${thumb}<div class="meta"><div class="sku">${r.sku || '—'}</div><div class="ttl" title="${(r.title || '').replace(/"/g, '"')}">${r.title || ''}</div>${vt ? `<div class="vt">${vt}</div>` : ''}${mfrn}${mfrname}${fmspec}${fac ? `<div class="fac">${fac}</div>` : ''}${price}${whenChip(r).html}</div><div class="acts">${actButtonsHTML(r)}</div>`;
+ const price = r.price ? `<span class="price" data-f="price">$${r.price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>` : '';
+ el.innerHTML = `${thumb}<div class="meta"><div class="sku" data-f="sku">${r.sku || '—'}</div><div class="ttl" data-f="ttl" title="${(r.title || '').replace(/"/g, '"')}">${r.title || ''}</div>${vt ? `<div class="vt" data-f="vt">${vt}</div>` : ''}${mfrn}${mfrname}${fmspec}${fac ? `<div class="fac" data-f="fac">${fac}</div>` : ''}${price}${whenChip(r).html}</div><div class="acts" data-f="act">${actButtonsHTML(r)}</div>`;
const acts = el.querySelector('.acts'); if (acts) acts.addEventListener('click', (e) => { e.stopPropagation(); handleActsClick(e); });
const meta = el.querySelector('.meta'); if (meta) meta.addEventListener('click', handleDrillClick);
if (r.url) { el.style.cursor = 'pointer'; el.onclick = () => window.open(r.url, '_blank', 'noopener,noreferrer'); }
diff --git a/public/vendors.html b/public/vendors.html
index 80b295e..fa44ad5 100644
--- a/public/vendors.html
+++ b/public/vendors.html
@@ -43,7 +43,34 @@
.dens { display:flex; align-items:center; gap:10px; font-size:11px; letter-spacing:.16em; text-transform:uppercase; color:#7d766a; }
.dens input { accent-color: var(--gold); width: 130px; }
+ /* Fields on/off menu — toggle any field on the card (down to image-only). */
+ .fieldmenu { position:relative; }
+ .fbtn { padding:9px 14px; border:1px solid var(--line); background:#fff; font-size:12px; letter-spacing:.1em;
+ text-transform:uppercase; color:#7d766a; cursor:pointer; border-radius:2px; }
+ .fbtn:hover { border-color:var(--gold); color:var(--ink); }
+ .fpop { position:absolute; top:calc(100% + 8px); right:0; background:#fff; border:1px solid var(--line);
+ border-radius:2px; box-shadow:0 14px 34px rgba(22,20,18,.16); padding:10px 6px; z-index:30; min-width:190px; }
+ .fpop[hidden] { display:none; }
+ .fpop label { display:flex; align-items:center; gap:10px; padding:7px 14px; font-size:12px; letter-spacing:.08em;
+ text-transform:uppercase; color:#4b463d; cursor:pointer; white-space:nowrap; }
+ .fpop label:hover { background:var(--paper); }
+ .fpop input { accent-color: var(--gold); }
+
.grid { display:grid; grid-template-columns: repeat(var(--cols), 1fr); gap: 26px; padding: 34px; }
+
+ /* List view — cards become horizontal rows (thumbnail left, meta right). */
+ .grid.list { display:block; padding: 20px 34px; }
+ .grid.list .card { flex-direction:row; align-items:stretch; margin-bottom:14px; }
+ .grid.list .card:hover { transform:none; }
+ .grid.list .collage { flex:0 0 clamp(120px, 22vw, 210px); aspect-ratio:4/3; }
+ .grid.list .meta { flex:1; display:flex; flex-direction:column; justify-content:center; }
+
+ /* Per-field hide switches driven by body.hf-<field> classes (set from the Fields menu). */
+ body.hf-image .collage { display:none; }
+ body.hf-name [data-f="name"] { display:none; }
+ body.hf-count [data-f="count"] { display:none; }
+ body.hf-status [data-f="status"] { display:none; }
+ body.hf-cta [data-f="cta"] { display:none; }
.card { background:#fff; border:1px solid var(--line); overflow:hidden; display:flex; flex-direction:column;
transition: box-shadow .25s, transform .25s; }
.card:hover { box-shadow: 0 14px 34px rgba(22,20,18,.14); transform: translateY(-3px); }
@@ -55,6 +82,8 @@
.card .meta { padding: 16px 18px 18px; }
.card h3 { font-family: Georgia, serif; font-weight:400; font-size:17px; letter-spacing:.03em; }
.card .sub { margin-top:6px; font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:#9a927f; }
+ .card .fline { margin-top:6px; font-size:11px; letter-spacing:.14em; text-transform:uppercase; color:#9a927f; }
+ .card .fline b { color:#6b6459; font-weight:600; }
.card .cta { margin-top:12px; font-size:11px; letter-spacing:.22em; text-transform:uppercase; color:var(--gold); }
.sentinel { height: 10px; }
@@ -93,11 +122,23 @@
<button id="fAll">All Vendors</button>
</div>
<select id="sort" aria-label="Sort">
- <option value="designs">Most Designs</option>
- <option value="az">Name A→Z</option>
- <option value="za">Name Z→A</option>
+ <option value="designs">Designs: Most</option>
+ <option value="designs_asc">Designs: Fewest</option>
+ <option value="az">Name: A→Z</option>
+ <option value="za">Name: Z→A</option>
+ <option value="status">Status: Private-Label First</option>
+ <option value="status_desc">Status: Standard First</option>
+ <option value="live">Available Now First</option>
</select>
- <label class="dens">Density <input type="range" id="density" min="2" max="8" step="1" value="4"></label>
+ <label class="dens">Density <input type="range" id="density" min="2" max="8" step="1" value="4"><span id="densN">4</span></label>
+ <div class="seg" role="tablist" aria-label="View">
+ <button id="vGrid" class="on">Grid</button>
+ <button id="vList">List</button>
+ </div>
+ <div class="fieldmenu">
+ <button id="fieldsBtn" class="fbtn" aria-haspopup="true" aria-expanded="false">Fields ▾</button>
+ <div class="fpop" id="fpop" hidden role="menu"></div>
+ </div>
</div>
<main class="grid" id="grid"></main>
@@ -108,22 +149,59 @@
<script>
const grid = document.getElementById('grid'), sentinel = document.getElementById('sentinel');
const menu = document.getElementById('menu');
+// Every field on a card is user-toggleable; [key, label, default-visible].
+const FIELDS = [
+ ['image', 'Image', true],
+ ['name', 'Name', true],
+ ['count', 'Designs', true],
+ ['status', 'Status', false],
+ ['cta', 'Shop link', true],
+];
+function loadFields(){
+ const def = {}; FIELDS.forEach(([k,,d]) => def[k] = d);
+ try { return Object.assign(def, JSON.parse(localStorage.getItem('adw_fields') || '{}')); }
+ catch(e){ return def; }
+}
+
const state = {
all: [], view: [], rendered: 0, BATCH: 40,
q: '', filter: localStorage.getItem('adw_filter') || 'live',
sort: localStorage.getItem('adw_sort') || 'designs',
cols: localStorage.getItem('adw_cols') || '4',
+ viewMode: localStorage.getItem('adw_view') || 'grid',
+ fields: loadFields(),
};
document.documentElement.style.setProperty('--cols', state.cols);
density.value = state.cols;
+densN.textContent = state.cols;
sort.value = state.sort;
setSeg();
+setView();
+buildFieldMenu();
+applyFields();
function setSeg(){
fLive.classList.toggle('on', state.filter==='live');
fAll.classList.toggle('on', state.filter==='all');
}
+function setView(){
+ grid.classList.toggle('list', state.viewMode === 'list');
+ vGrid.classList.toggle('on', state.viewMode === 'grid');
+ vList.classList.toggle('on', state.viewMode === 'list');
+}
+function applyFields(){
+ FIELDS.forEach(([k]) => document.body.classList.toggle('hf-' + k, !state.fields[k]));
+}
+function buildFieldMenu(){
+ fpop.innerHTML = FIELDS.map(([k,label]) =>
+ `<label><input type="checkbox" data-field="${k}"${state.fields[k] ? ' checked' : ''}> ${label}</label>`).join('');
+ fpop.querySelectorAll('input').forEach(cb => cb.addEventListener('change', () => {
+ state.fields[cb.dataset.field] = cb.checked;
+ localStorage.setItem('adw_fields', JSON.stringify(state.fields));
+ applyFields();
+ }));
+}
fetch('/api/vendors').then(r=>r.json()).then(d=>{
state.all = d.vendors || [];
@@ -132,13 +210,25 @@ fetch('/api/vendors').then(r=>r.json()).then(d=>{
apply();
});
+// Every field on a vendor record gets a sorter. Ties break on name so order is stable.
+const nm = v => String(v.name || '');
+const ac = v => (v.active_count || 0);
+const priv = v => (v.is_private_label ? 1 : 0);
+const SORTERS = {
+ designs: (a,b)=> (ac(b)-ac(a)) || nm(a).localeCompare(nm(b)),
+ designs_asc: (a,b)=> (ac(a)-ac(b)) || nm(a).localeCompare(nm(b)),
+ az: (a,b)=> nm(a).localeCompare(nm(b)),
+ za: (a,b)=> nm(b).localeCompare(nm(a)),
+ status: (a,b)=> (priv(b)-priv(a)) || nm(a).localeCompare(nm(b)),
+ status_desc: (a,b)=> (priv(a)-priv(b)) || nm(a).localeCompare(nm(b)),
+ live: (a,b)=> ((ac(b)>0)-(ac(a)>0)) || (ac(b)-ac(a)) || nm(a).localeCompare(nm(b)),
+};
+
function apply(){
let v = state.all;
if (state.filter==='live') v = v.filter(x=>x.active_count>0);
if (state.q) { const q=state.q.toLowerCase(); v = v.filter(x=>x.name.toLowerCase().includes(q)); }
- if (state.sort==='az') v=[...v].sort((a,b)=>a.name.localeCompare(b.name));
- else if (state.sort==='za') v=[...v].sort((a,b)=>b.name.localeCompare(a.name));
- else v=[...v].sort((a,b)=>b.active_count-a.active_count || a.name.localeCompare(b.name));
+ v = [...v].sort(SORTERS[state.sort] || SORTERS.designs);
state.view = v; state.rendered = 0; grid.innerHTML=''; renderMore();
}
@@ -150,11 +240,12 @@ function card(v){
? imgs.map(u=>`<img loading="lazy" src="${u.replace(/"/g,'')}" alt="${v.name} design">`).join('')
: `<div class="ph">${v.name.charAt(0)}</div>`;
el.innerHTML = `
- <div class="collage${imgs.length<=1?' single':''}">${cells}</div>
+ <div class="collage${imgs.length<=1?' single':''}" data-f="image">${cells}</div>
<div class="meta">
- <h3></h3>
- <div class="sub">${v.active_count>0 ? v.active_count.toLocaleString()+' designs live' : 'By inquiry'}</div>
- <div class="cta">Shop the Line →</div>
+ <h3 data-f="name"></h3>
+ <div class="sub" data-f="count">${v.active_count>0 ? v.active_count.toLocaleString()+' designs live' : 'By inquiry'}</div>
+ <div class="fline" data-f="status"><b>Status</b> ${v.is_private_label ? 'Private Label' : (v.active_count>0 ? 'Available Now' : 'By Inquiry')}</div>
+ <div class="cta" data-f="cta">Shop the Line →</div>
</div>`;
el.querySelector('h3').textContent = v.name;
return el;
@@ -171,9 +262,18 @@ new IntersectionObserver(es=>{ if(es[0].isIntersecting) renderMore(); }, {rootMa
q.addEventListener('input', e=>{ state.q=e.target.value.trim(); apply(); });
sort.addEventListener('change', e=>{ state.sort=e.target.value; localStorage.setItem('adw_sort',state.sort); apply(); });
density.addEventListener('input', e=>{ state.cols=e.target.value; localStorage.setItem('adw_cols',state.cols);
- document.documentElement.style.setProperty('--cols', state.cols); });
+ densN.textContent=state.cols; document.documentElement.style.setProperty('--cols', state.cols); });
fLive.onclick=()=>{ state.filter='live'; localStorage.setItem('adw_filter','live'); setSeg(); apply(); };
fAll.onclick=()=>{ state.filter='all'; localStorage.setItem('adw_filter','all'); setSeg(); apply(); };
+
+vGrid.onclick=()=>{ state.viewMode='grid'; localStorage.setItem('adw_view','grid'); setView(); };
+vList.onclick=()=>{ state.viewMode='list'; localStorage.setItem('adw_view','list'); setView(); };
+
+// Fields menu open/close (click-away closes).
+fieldsBtn.addEventListener('click', (e)=>{ e.stopPropagation();
+ const open = fpop.hidden; fpop.hidden = !open; fieldsBtn.setAttribute('aria-expanded', String(open)); });
+document.addEventListener('click', (e)=>{ if (!fpop.hidden && !e.target.closest('.fieldmenu')) {
+ fpop.hidden = true; fieldsBtn.setAttribute('aria-expanded','false'); } });
</script>
</body>
</html>
diff --git a/server.js b/server.js
index 8b71b5a..a82f0a3 100644
--- a/server.js
+++ b/server.js
@@ -930,6 +930,8 @@ const sorters = {
type: (a, b) => str(a.type).localeCompare(str(b.type)) || str(a.title).localeCompare(str(b.title)),
series: (a, b) => str(a.series).localeCompare(str(b.series)) || str(a.sku).localeCompare(str(b.sku)),
sku: (a, b) => str(a.sku).localeCompare(str(b.sku)),
+ mfr: (a, b) => str(a.mfr_number).localeCompare(str(b.mfr_number)) || str(a.sku).localeCompare(str(b.sku)),
+ mfrname: (a, b) => str(a.mfr_name).localeCompare(str(b.mfr_name)) || str(a.sku).localeCompare(str(b.sku)),
pattern: (a, b) => str(a.pattern).localeCompare(str(b.pattern)),
status: (a, b) => str(a.status).localeCompare(str(b.status)),
lifecycle: (a, b) => str(a.lifecycle).localeCompare(str(b.lifecycle)) || str(a.sku).localeCompare(str(b.sku)),
@@ -945,6 +947,7 @@ const sorters = {
// per-sort "row actually has this value" — used to keep empties last on dir=desc
const HAS_VALUE = {
sku: (r) => r.sku != null && r.sku !== '', title: (r) => !!r.title, vendor: (r) => !!r.vendor,
+ mfr: (r) => !!r.mfr_number, mfrname: (r) => !!r.mfr_name,
type: (r) => !!r.type, series: (r) => !!r.series, pattern: (r) => !!r.pattern, status: (r) => !!r.status, lifecycle: (r) => !!r.lifecycle,
price: (r) => r.price != null, price_asc: (r) => r.price != null, price_desc: (r) => r.price != null,
color: (r) => r.colors.length > 0, style: (r) => r.styles.length > 0, material: (r) => r.materials.length > 0,
← 95446fa microsites.html: add list/grid toggle + per-field on/off men
·
back to All Designerwallcoverings
·
nav-agent: universal grid-controls drop-in on internal viewe 3b584b2 →