← back to All Designerwallcoverings
Add /api/discontinued + red successor banner: dead/archived SKU resolves (dash/-SAMPLE insensitive) → links live successor
55b78db287967223f23b261f13b8f558cd8b641a · 2026-07-08 10:59:46 -0700 · Steve
Files touched
M public/index.htmlM server.js
Diff
commit 55b78db287967223f23b261f13b8f558cd8b641a
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Jul 8 10:59:46 2026 -0700
Add /api/discontinued + red successor banner: dead/archived SKU resolves (dash/-SAMPLE insensitive) → links live successor
---
public/index.html | 268 ++++++++++++++++++++++++++++++++++++++++++++++++++++--
server.js | 227 ++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 485 insertions(+), 10 deletions(-)
diff --git a/public/index.html b/public/index.html
index f32e25a..59560dd 100644
--- a/public/index.html
+++ b/public/index.html
@@ -24,6 +24,26 @@
.viewseg button { background:var(--card); color:var(--mut); border:none; padding:7px 13px; font-size:12px; cursor:pointer; }
.viewseg button.on { background:var(--accent); color:#1a1407; font-weight:600; }
#densWrap input { accent-color:var(--accent); width:110px; }
+ /* FileMaker source-of-truth header cluster: status chip + verification toggles + bulk preview */
+ #fmBar { gap:6px; border-left:1px solid var(--line); padding-left:10px; }
+ .fmstat { color:#8aa6c9; font-size:11px; font-variant-numeric:tabular-nums; }
+ .fmstat b { color:#e0b46a; }
+ .fmtoggle { background:var(--card); color:var(--mut); border:1px solid var(--line); border-radius:7px;
+ padding:5px 9px; font-size:11px; cursor:pointer; font-family:inherit; white-space:nowrap; }
+ .fmtoggle:hover { border-color:var(--accent); color:var(--txt); }
+ .fmtoggle.on { background:#2a1d0c; color:#e0b46a; border-color:#e0b46a; font-weight:600; }
+ #fmBulk { color:#7fd0a0; border-color:#1a3a2a; }
+ #fmBulk:hover { border-color:#7fd0a0; }
+ /* bulk preview modal */
+ #fmModal { position:fixed; inset:0; background:rgba(0,0,0,.6); display:none; align-items:center; justify-content:center; z-index:50; }
+ #fmModal.on { display:flex; }
+ #fmModal .box { background:#0f0f13; border:1px solid var(--line); border-radius:10px; max-width:560px; width:92%;
+ max-height:80vh; overflow:auto; padding:16px 18px; font-size:12.5px; color:var(--txt); }
+ #fmModal h3 { margin:0 0 8px; font-size:14px; color:var(--accent); }
+ #fmModal .prow { border-top:1px solid var(--line); padding:6px 0; }
+ #fmModal .prow .sk { color:#8aa6c9; font-weight:600; }
+ #fmModal .prow .c { color:#7fd0a0; } #fmModal .prow .k { color:var(--mut); }
+ #fmModal .close { float:right; cursor:pointer; color:var(--mut); border:1px solid var(--line); border-radius:6px; padding:2px 8px; }
#railToggle { display:none; }
/* ── layout: Amazon-style left rail + content ── */
@@ -75,6 +95,42 @@
.mfrn b { color:var(--mut); font-weight:600; letter-spacing:.03em; }
.mfrname { margin-top:2px; font-size:calc(10px*var(--fs)); color:var(--mut); }
.mfrname b { letter-spacing:.03em; }
+ /* FileMaker source-of-truth: the FM mfr# shown next to Shopify's, + mismatch flag.
+ "· FM 12345" in green when it agrees, amber + ⚠ + tinted row when it disagrees. */
+ .mfrn .fmv { color:#7fc98a; }
+ .mfrn.mismatch { background:#2a1d0c; border-radius:4px; padding:1px 4px; margin-left:-4px; }
+ .mfrn.mismatch .fmv { color:#e0b46a; }
+ .mfrn .mmflag { color:#e0b46a; cursor:help; }
+ .fmspec { margin-top:3px; font-size:calc(9.5px*var(--fs)); color:var(--mut); line-height:1.5; }
+ .fmspec b { color:#8aa6c9; font-weight:600; letter-spacing:.02em; }
+ .badge.disco { background:#3a1616; color:#e0b3b3; }
+ /* discontinued → red banner (search yields no live grid results) + red-card successor line */
+ #discoBanner { display:none; margin:12px 14px 0; padding:14px 16px; border:1px solid #c0392b;
+ border-left:5px solid #c0392b; border-radius:10px; background:#2a1414; color:#f0d6d6; }
+ #discoBanner.show { display:block; }
+ #discoBanner .dbhead { font-size:15px; font-weight:700; color:#e88; letter-spacing:.01em; }
+ #discoBanner .dbsucc { margin-top:7px; font-size:14px; color:#f0d6d6; }
+ #discoBanner .dbsucc a { color:#ff9b8a; font-weight:700; text-decoration:underline; }
+ #discoBanner .dbsucc .arr { opacity:.7; margin:0 6px; }
+ #discoBanner .dbnone { margin-top:6px; font-size:12.5px; color:#c99; }
+ .card.disco-red { outline:2px solid #c0392b; outline-offset:-2px; }
+ .card.disco-red .disco-succ { display:block; margin-top:5px; font-size:11px; color:#ff9b8a; line-height:1.35; }
+ .card.disco-red .disco-succ a { color:#ff9b8a; font-weight:700; text-decoration:underline; }
+ /* FileMaker→Shopify push button + diff popover */
+ .acts .im.pushbtn, td.acts-td .im.pushbtn { border-color:#1a3a2a; color:#7fd0a0; }
+ .acts .im.pushbtn:hover, td.acts-td .im.pushbtn:hover { border-color:#7fd0a0; }
+ .pushpop { font-size:calc(10px*var(--fs)); color:var(--txt); background:#0c0c10; border:1px solid var(--line);
+ border-radius:5px; padding:6px; margin-top:2px; line-height:1.55; }
+ .pushpop[hidden] { display:none; }
+ .pushpop .chg { display:block; margin:2px 0; }
+ .pushpop .chg .k { color:#8aa6c9; font-weight:600; }
+ .pushpop .chg .from { color:#e0b3b3; text-decoration:line-through; opacity:.75; }
+ .pushpop .chg .to { color:#7fd0a0; }
+ .pushpop .none { color:var(--mut); }
+ .pushpop .skip { color:var(--mut); font-style:italic; }
+ .pushpop .commitbtn { margin-top:5px; width:100%; padding:4px; border-radius:5px; font-family:inherit; font-weight:600;
+ border:1px solid #4a341a; background:transparent; color:#e0b46a; cursor:pointer; }
+ .pushpop .commitbtn.gated { opacity:.55; cursor:not-allowed; }
.fac { margin-top:4px; font-size:calc(10.5px*var(--fs)); color:var(--accent); }
.price { color:#e8e8ea; font-size:calc(12.5px*var(--fs)); font-weight:600; margin-top:4px; display:block; }
/* active-or-staged date chip — admin curation aid (Steve rule: admin cards show created date+time) */
@@ -177,6 +233,12 @@
</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="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>
+ <button id="fmDisco" class="fmtoggle" type="button" title="Include rows FileMaker Pro marks discontinued (hidden by default)">Show FM-disco</button>
+ <button id="fmBulk" class="fmtoggle" type="button" title="Dry-run the FileMaker→Shopify push for the loaded products (name / mfr / width / specs)">Preview sync ⇪</button>
+ </span>
</header>
<div class="wrap">
@@ -195,6 +257,7 @@
</aside>
<main>
+ <div id="discoBanner"></div>
<div class="grid" id="grid"></div>
<div class="listwrap" id="listwrap" style="display:none">
<table>
@@ -207,6 +270,8 @@
</main>
</div>
+<div id="fmModal"><div class="box"><span class="close" id="fmModalClose">✕ close</span><h3>FileMaker → Shopify — dry-run preview</h3><div id="fmModalBody">…</div></div></div>
+
<script>
const grid = document.getElementById('grid'), tbody = document.getElementById('tbody');
const listwrap = document.getElementById('listwrap');
@@ -223,6 +288,9 @@ let state = {
vendors: loadSet('all_vendors'), types: loadSet('all_types'), series: loadSet('all_series'), lifecycles: loadSet('all_lifecycles'),
colors: loadSet('all_colors'), styles: loadSet('all_styles'), materials: loadSet('all_materials'),
prices: loadSet('all_prices'), images: loadSet('all_images'),
+ // FileMaker verification views: mismatch = only rows whose FM mfr# ≠ Shopify's; fmDisco =
+ // include the rows FileMaker Pro marks discontinued (hidden by default).
+ mismatch: urlq.get('mismatch') === '1', fmDisco: urlq.get('fm_disco') === '1',
offset: 0, total: 0, loading: false, done: false,
};
// per-row UI state: expanded? + type-ahead needle (vendor/type rows have hundreds of values)
@@ -244,6 +312,44 @@ const setCols = () => {
setCols();
dens.oninput = () => { setCols(); localStorage.setItem('all_cols', dens.value); };
+/* ── FileMaker verification controls ── */
+const fmMismatchBtn = document.getElementById('fmMismatch');
+const fmDiscoBtn = document.getElementById('fmDisco');
+const fmBulkBtn = document.getElementById('fmBulk');
+const syncToggles = () => { fmMismatchBtn.classList.toggle('on', state.mismatch); fmDiscoBtn.classList.toggle('on', state.fmDisco); };
+syncToggles();
+fmMismatchBtn.onclick = () => { state.mismatch = !state.mismatch; syncToggles(); refreshFacets(); reset(); };
+fmDiscoBtn.onclick = () => { state.fmDisco = !state.fmDisco; syncToggles(); refreshFacets(); reset(); };
+fmBulkBtn.onclick = () => fmBulkPreview();
+
+// Bulk dry-run: send the loaded FileMaker-joined SKUs (≤100 per call) to the push worker and
+// summarize how many products have real changes. Review-only — no live write from here.
+async function fmBulkPreview() {
+ const modal = document.getElementById('fmModal'), body = document.getElementById('fmModalBody');
+ const skus = [...new Set(FM_LOADED)].slice(0, 100);
+ modal.classList.add('on');
+ if (!skus.length) { body.innerHTML = '<span class="k">No FileMaker-joined products loaded. Scroll or filter to load some, or use ⚠ Mismatches.</span>'; return; }
+ body.innerHTML = `<span class="k">◌ Comparing ${skus.length} product${skus.length > 1 ? 's' : ''} (of ${FM_LOADED.length} loaded) FileMaker → Shopify…</span>`;
+ try {
+ const res = await fetch('/api/shopify-push-bulk', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ skus }) });
+ const d = await res.json();
+ const changed = (d.results || []).filter((r) => r.ok && r.change_count > 0);
+ let html = `<div class="prow"><b>${d.products_with_changes}</b> of ${d.evaluated} products have proposed changes · <b>${d.total_field_changes}</b> field updates total.${d.push_enabled ? '' : ' <span class="k">(live writes gated — dry-run only)</span>'}</div>`;
+ if (!changed.length) html += `<div class="prow k">Everything loaded already matches FileMaker. ✓</div>`;
+ for (const r of changed.slice(0, 40)) {
+ const parts = [];
+ if (r.title_change) parts.push('title');
+ const mfs = (r.metafield_changes || []).map((c) => c.field);
+ parts.push(...[...new Set(mfs)]);
+ html += `<div class="prow"><span class="sk">${r.dw_sku}</span> — <span class="c">${r.change_count} change${r.change_count > 1 ? 's' : ''}</span> <span class="k">(${parts.join(', ')})</span></div>`;
+ }
+ if (changed.length > 40) html += `<div class="prow k">…and ${changed.length - 40} more.</div>`;
+ body.innerHTML = html;
+ } catch (e) { body.innerHTML = '<span class="k">Bulk preview failed — try again.</span>'; }
+}
+document.getElementById('fmModalClose').onclick = () => document.getElementById('fmModal').classList.remove('on');
+document.getElementById('fmModal').onclick = (e) => { if (e.target.id === 'fmModal') e.currentTarget.classList.remove('on'); };
+
/* accordion sections: persist open/closed per section (collapsed by default, Astek-style) */
document.querySelectorAll('details.sec').forEach((d) => {
d.open = localStorage.getItem('all_sec_' + d.dataset.k) === '1';
@@ -341,11 +447,67 @@ function facetParams(extra = {}) {
vendors: [...state.vendors].join(','), types: [...state.types].join(','), series: [...state.series].join(','),
lifecycles: [...state.lifecycles].join(','), colors: [...state.colors].join(','),
styles: [...state.styles].join(','), materials: [...state.materials].join(','),
- prices: [...state.prices].join(','), images: [...state.images].join(','), ...extra,
+ prices: [...state.prices].join(','), images: [...state.images].join(','),
+ ...(state.mismatch ? { mismatch: '1' } : {}), ...(state.fmDisco ? { fm_disco: '1' } : {}), ...extra,
});
}
-function reset() { window.scrollTo(0, 0); grid.innerHTML = ''; tbody.innerHTML = ''; state.offset = 0; state.done = false; statusEl.textContent = 'loading…'; load().then(() => pump()); }
+const FM_LOADED = []; // SKUs (with FileMaker data) currently loaded — the bulk-preview scope
+function reset() { window.scrollTo(0, 0); grid.innerHTML = ''; tbody.innerHTML = ''; FM_LOADED.length = 0; state.offset = 0; state.done = false; hideDiscoBanner(); statusEl.textContent = 'loading…'; load().then(() => { pump(); checkDiscontinued(); }); }
+
+// ── discontinued-SKU resolver → red banner + red-card successor line ──────────────────
+// When the current search resolves to a discontinued/deleted/archived SKU, /api/discontinued
+// links its live successor. Two cases: (a) the dead SKU is NOT in the live grid → show a red
+// banner above the grid; (b) it IS in the grid (archived lifecycle) → badge that card red + add
+// the successor line. successor.url points at the live product page.
+const discoBanner = document.getElementById('discoBanner');
+let discoToken = 0;
+function hideDiscoBanner() {
+ discoBanner.className = ''; discoBanner.innerHTML = '';
+ document.querySelectorAll('.card.disco-red').forEach((c) => { c.classList.remove('disco-red'); const s = c.querySelector('.disco-succ'); if (s) s.remove(); });
+}
+function succLineHTML(d) {
+ const s = d.successor;
+ if (!s || !s.sku) return `<div class="dbnone">No live successor identified — search the catalog for a similar pattern.</div>`;
+ const label = esc(s.sku + (s.title ? ' · ' + s.title : ''));
+ return s.url
+ ? `<div class="dbsucc">Now available as <span class="arr">→</span> <a href="${esc(s.url)}" target="_blank" rel="noopener noreferrer">${label}</a></div>`
+ : `<div class="dbsucc">Now available as <span class="arr">→</span> ${label}</div>`;
+}
+async function checkDiscontinued() {
+ const q = (state.q || '').trim();
+ const tok = ++discoToken;
+ // only worth a lookup for a SKU-ish query (letters+digits, no spaces) — avoids firing on
+ // "red"/"damask" free-text searches. Dash/-SAMPLE handled server-side.
+ if (!q || /\s/.test(q) || !/[a-z]/i.test(q) || !/\d/.test(q)) return;
+ try {
+ const res = await fetch('/api/discontinued?sku=' + encodeURIComponent(q));
+ const d = await res.json();
+ if (tok !== discoToken) return; // a newer search superseded this one
+ if (!d.found || !d.discontinued) return;
+ // is the dead SKU already rendered as a card? (archived lifecycle appears in the grid)
+ const card = document.querySelector(`.card[data-sku="${(window.CSS && CSS.escape) ? CSS.escape(d.sku) : d.sku}"]`);
+ if (card) {
+ card.classList.add('disco-red');
+ if (!card.querySelector('.disco-succ')) {
+ const meta = card.querySelector('.meta') || card;
+ const div = document.createElement('div'); div.className = 'disco-succ';
+ const s = d.successor;
+ div.innerHTML = s && s.sku
+ ? (s.url ? `⛔ Discontinued · now → <a href="${esc(s.url)}" target="_blank" rel="noopener noreferrer">${esc(s.sku)}</a>` : `⛔ Discontinued · now → ${esc(s.sku)}`)
+ : `⛔ Discontinued`;
+ div.addEventListener('click', (e) => e.stopPropagation());
+ meta.appendChild(div);
+ }
+ }
+ // always show the banner when the query has no live grid results; also show it alongside a
+ // badged archived card so the successor call-to-action is unmissable.
+ if (state.total === 0 || card) {
+ discoBanner.innerHTML = `<div class="dbhead">⛔ Discontinued — ${esc(d.title || '')} (${esc(d.sku)})</div>${succLineHTML(d)}`;
+ discoBanner.className = 'show';
+ }
+ } catch (e) { /* endpoint degrades silently — grid still works */ }
+}
function applyFilterChange() { persistFacets(); refreshFacets(); reset(); }
function toggleSet(set, val) { set.has(val) ? set.delete(val) : set.add(val); applyFilterChange(); }
@@ -433,9 +595,17 @@ function renderActive() {
async function refreshFacets() {
try {
const res = await fetch('/api/facets?' + facetParams());
- renderFacets(await res.json());
+ const fj = await res.json();
+ renderFacets(fj);
+ if (fj.filemaker) updateFmStat(fj.filemaker);
} catch (e) { /* facets degrade silently — grid still works */ }
}
+// Header FileMaker chip: how many loaded rows are joined to the WALLPAPER file + mfr# mismatches.
+function updateFmStat(fm) {
+ const el = document.getElementById('fmStat'); if (!el || !fm) return;
+ el.innerHTML = `FM <b>${(fm.matched || 0).toLocaleString()}</b> joined · <b>${(fm.mismatch || 0).toLocaleString()}</b> mfr⚠`;
+ el.title = `${(fm.rows || 0).toLocaleString()} FileMaker mirror rows · ${(fm.discontinued_hidden || 0).toLocaleString()} discontinued hidden · ${(fm.fm_only_live || 0).toLocaleString()} FileMaker-only live (not in Shopify)`;
+}
/* ── renderers ── */
// Lifecycle label the surface lists: "Active on site" / "Staged" / "Archived".
@@ -498,7 +668,12 @@ function actButtonsHTML(r) {
const live = r.live_capable
? `<button class="im livebtn" type="button" data-sku="${esc(r.sku || '')}" title="Metered vendor-portal scrape (~$0.15–0.30 · cached 10 min)">Check Live 🔄</button>`
: `<button class="im disabled" type="button" title="${esc(r.live_reason || 'live check unavailable for this vendor')}">Check Live 🔄</button>`;
- return `${web}<button class="intbtn" type="button">Internal ▾</button><div class="intpanel" hidden>${admin}${viewer}${item}${stock}${live}${stockPopHTML(r.stock)}<div class="livepop" hidden></div></div>`;
+ // Update Shopify = preview (dry-run) the FileMaker→Shopify push of name/mfr/width/all specs
+ // for THIS product. Only offered when the SKU is in the WALLPAPER file (there's FM truth to push).
+ const push = r.fm_present
+ ? `<button class="im pushbtn" type="button" data-sku="${esc(r.sku || '')}" title="Preview the FileMaker→Shopify update (name / mfr / width / specs) — dry-run">Update Shopify ⇪</button><div class="pushpop" hidden></div>`
+ : '';
+ return `${web}<button class="intbtn" type="button">Internal ▾</button><div class="intpanel" hidden>${admin}${viewer}${item}${stock}${live}${push}${stockPopHTML(r.stock)}<div class="livepop" hidden></div></div>`;
}
// Delegated within an .acts container (which stops propagation so the card's own
@@ -510,6 +685,57 @@ function handleActsClick(e) {
if (sb) { const pop = sb.closest('.intpanel').querySelector('.stockpop'); if (pop) pop.hidden = !pop.hidden; return; }
const lb = e.target.closest('.livebtn');
if (lb) { fireLiveCheck(lb); return; }
+ const cb = e.target.closest('.commitbtn');
+ if (cb) { fireCommit(cb); return; }
+ const pb = e.target.closest('.pushbtn');
+ if (pb) { firePush(pb); return; }
+}
+
+// Preview (dry-run) the FileMaker→Shopify push for one SKU and render the diff. The Commit
+// button appears only when the diff is non-empty AND the server reports push_enabled (the
+// Steve-gated SHOPIFY_PUSH_ENABLED switch) — otherwise it renders gated + explains why.
+function renderPush(pop, d) {
+ if (!d || d.ok === false) { pop.innerHTML = `<span class="none">${esc((d && d.reason) || 'push preview failed')}</span>`; return; }
+ const rows = [];
+ if (d.title_change) rows.push(`<span class="chg"><span class="k">Title</span> <span class="from">${esc(d.title_change.from || '—')}</span> → <span class="to">${esc(d.title_change.to)}</span></span>`);
+ for (const c of (d.metafield_changes || [])) rows.push(`<span class="chg"><span class="k">${esc(c.metafield)}</span> <span class="from">${esc(c.from || '—')}</span> → <span class="to">${esc(c.to)}</span></span>`);
+ const skips = (d.skipped_richer || []).length;
+ let html;
+ if (!rows.length) {
+ html = `<span class="none">✓ Shopify already matches FileMaker — nothing to push.</span>`;
+ } else {
+ const commit = d.push_enabled
+ ? `<button class="commitbtn" type="button" data-sku="${esc(d.dw_sku)}">Commit ${rows.length} change${rows.length > 1 ? 's' : ''} to LIVE Shopify</button>`
+ : `<button class="commitbtn gated" type="button" title="Live writes are gated. Steve enables with SHOPIFY_PUSH_ENABLED=1 on the server." disabled>Commit gated — dry-run only</button>`;
+ html = rows.join('') + commit;
+ }
+ if (skips) html += `<span class="skip">+ ${skips} field${skips > 1 ? 's' : ''} left as-is (Shopify value already richer)</span>`;
+ pop.innerHTML = html;
+}
+function firePush(pb) {
+ if (pb.dataset.busy === '1') return;
+ const pop = pb.parentElement.querySelector('.pushpop');
+ const sku = pb.dataset.sku;
+ pop.hidden = false; pop.innerHTML = '<span class="none">◌ Comparing FileMaker → Shopify…</span>';
+ pb.dataset.busy = '1'; const orig = pb.textContent; pb.textContent = 'Comparing…';
+ fetch('/api/shopify-push?sku=' + encodeURIComponent(sku), { method: 'POST' })
+ .then((r) => r.json()).then((d) => renderPush(pop, d))
+ .catch(() => { pop.innerHTML = '<span class="none">preview failed — try again</span>'; })
+ .finally(() => { pb.dataset.busy = ''; pb.textContent = orig; });
+}
+// Commit is the GATED live write. Requires a confirm; server refuses unless SHOPIFY_PUSH_ENABLED=1.
+function fireCommit(cb) {
+ if (cb.disabled || cb.dataset.busy === '1') return;
+ const sku = cb.dataset.sku;
+ if (!confirm(`Write FileMaker's values to the LIVE Shopify product for ${sku}?\n\nThis updates the customer-facing store.`)) return;
+ cb.dataset.busy = '1'; cb.textContent = 'Committing…';
+ fetch('/api/shopify-push?sku=' + encodeURIComponent(sku) + '&commit=1', { method: 'POST' })
+ .then((r) => r.json()).then((d) => {
+ cb.textContent = d.committed ? '✓ Committed to live Shopify' : (d.blocked ? 'Blocked — writes gated' : 'Commit failed');
+ cb.classList.add('gated');
+ })
+ .catch(() => { cb.textContent = 'Commit failed — try again'; })
+ .finally(() => { cb.dataset.busy = ''; });
}
// One-click METERED live check. Fires immediately (no confirm — server rate-limits + caches).
@@ -542,18 +768,42 @@ function fireLiveCheck(lb) {
.finally(() => { lb.dataset.busy = ''; lb.textContent = orig; });
}
+// Mfr # chip — shows BOTH Shopify's stored number AND what FileMaker Pro shows, side by side,
+// with a ⚠ + tinted row when they disagree (the mfr-number verification workflow). FM value in
+// green when it agrees. When the SKU isn't in the WALLPAPER file, just the Shopify number shows.
+function mfrHTML(r) {
+ const shop = r.mfr_number ? esc(r.mfr_number) : '—';
+ if (r.fm_present && r.fm_mfr_number) {
+ const mm = r.mfr_mismatch;
+ return `<div class="mfrn${mm ? ' mismatch' : ''}"><b>Mfr #</b> ${shop} <span class="fmv" title="what FileMaker Pro shows as the mfr number">· FM ${esc(r.fm_mfr_number)}</span>${mm ? ' <span class="mmflag" title="FileMaker and Shopify mfr numbers disagree — verify">⚠</span>' : ''}</div>`;
+ }
+ return r.mfr_number ? `<div class="mfrn"><b>Mfr #</b> ${shop}</div>` : '';
+}
+// A compact line of FileMaker specs (width + up to 3 others) — the source-of-truth values
+// available to push. Only rendered when the SKU is in the WALLPAPER file and has specs.
+function fmSpecsHTML(r) {
+ if (!r.fm_present) return '';
+ const bits = [];
+ if (r.fm_width) bits.push(`W: ${esc(r.fm_width)}`);
+ const sp = r.fm_specs || {};
+ for (const k of ['content', 'repeat', 'collection', 'length']) { if (sp[k] && bits.length < 4) bits.push(`${k[0].toUpperCase() + k.slice(1)}: ${esc(String(sp[k]))}`); }
+ return bits.length ? `<div class="fmspec"><b>FM</b> ${bits.join(' · ')}</div>` : '';
+}
function card(r) {
const el = document.createElement('div'); el.className = 'card';
+ if (r.sku) el.dataset.sku = String(r.sku);
const stBadge = (r.lifecycle && r.lifecycle !== 'Active on site') ? `<span class="badge ${lifeClass(r)}">${r.lifecycle}</span>` : '';
+ const discoBadge = r.fm_discontinued ? `<span class="badge disco" title="FileMaker Pro: discontinued ${esc(r.fm_disco_date || '')}">FM Disco</span>` : '';
const thumb = r.image
- ? `<div class="thumb" style="background-image:url('${r.image}')">${stBadge}</div>`
- : `<div class="thumb empty">${stBadge}no image</div>`;
+ ? `<div class="thumb" style="background-image:url('${r.image}')">${stBadge}${discoBadge}</div>`
+ : `<div class="thumb empty">${stBadge}${discoBadge}no image</div>`;
const vt = [r.vendor, r.type].filter(Boolean).join(' · ');
- const mfrn = r.mfr_number ? `<div class="mfrn"><b>Mfr #</b> ${esc(r.mfr_number)}</div>` : '';
+ const mfrn = mfrHTML(r);
const mfrname = r.mfr_name ? `<div class="mfrname"><b>Mfr</b> ${esc(r.mfr_name)}</div>` : '';
+ const fmspec = fmSpecsHTML(r);
const fac = [...(r.colors || []), ...(r.styles || []), ...(r.materials || [])].slice(0, 4).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}${fac ? `<div class="fac">${fac}</div>` : ''}${price}${whenChip(r).html}</div><div class="acts">${actButtonsHTML(r)}</div>`;
+ 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 acts = el.querySelector('.acts'); if (acts) acts.addEventListener('click', (e) => { e.stopPropagation(); handleActsClick(e); });
if (r.url) { el.style.cursor = 'pointer'; el.onclick = () => window.open(r.url, '_blank', 'noopener,noreferrer'); }
return el;
@@ -591,7 +841,7 @@ async function load() {
const data = await res.json();
state.total = data.total;
const frag = document.createDocumentFragment();
- data.rows.forEach((r) => frag.appendChild(state.view === 'list' ? listRow(r) : card(r)));
+ data.rows.forEach((r) => { if (r.fm_present && r.sku) FM_LOADED.push(r.sku); frag.appendChild(state.view === 'list' ? listRow(r) : card(r)); });
(state.view === 'list' ? tbody : grid).appendChild(frag);
state.offset += data.rows.length;
state.done = state.offset >= data.total || data.rows.length === 0;
diff --git a/server.js b/server.js
index 1f10c1e..7500ca6 100644
--- a/server.js
+++ b/server.js
@@ -66,6 +66,18 @@ const slugify = (v) => String(v || '').toLowerCase().normalize('NFD')
.replace(/[̀-ͯ]/g, '').replace(/&/g, ' and ')
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
+// ── FileMaker WALLPAPER mirror join (source-of-truth enrichment) ──────────────────
+// FMPro's `comboskuwithdash` (bare, e.g. "DWSW-5000270") matches the Shopify dw_sku once
+// the "-SAMPLE" variant suffix is stripped; case-insensitive. That's the single join key
+// between the FileMaker master and this catalog (verified: modern traditional-vendor lines
+// overlap; modern material series are Shopify-only; the legacy tail is FileMaker-only).
+const fmKey = (sku) => String(sku || '').toUpperCase().replace(/-SAMPLE$/, '').trim();
+// Normalize an mfr number for MISMATCH comparison only — strip the human notes FMPro appends
+// ("SBG3207 -- MUST USE QUOTE #G45" → "SBG3207") plus punctuation/case, so the verification
+// flag fires on real identity differences, not formatting noise. Display keeps the raw value.
+const cleanMfr = (raw) => String(raw || '').split(/--|·|;|\bMUST\b|\bQUOTE\b/i)[0]
+ .replace(/[^A-Za-z0-9./-]+/g, '').toUpperCase();
+
// HARD front-facing rule (same list build-data.py enforced for the vendor directory):
// banned upstream/private-label source names never ship on this public surface — rows
// whose vendor OR title leaks one are excluded (their products surface under the label).
@@ -177,6 +189,9 @@ let ROWS = [];
let ROWS_INTERNAL = []; // internal full-catalog feed (every non-archived SKU, leak-sanitized)
let LOADED_AT = null;
let pool = null;
+let FM = new Map(); // fmKey(dw_sku) → FileMaker WALLPAPER mirror row (source of truth)
+let FM_MATCHED = new Set(); // fmKeys that joined a shopify_products row this snapshot
+let FM_STATS = { rows: 0, matched: 0, discontinued_hidden: 0, mismatch: 0, fm_only_live: 0 };
let STOCK = new Map(); // upper(sku) → public-safe stored stock snapshot (the /livestock FREE path)
let VENDOR_VIEWER = new Map(); // slugify(vendor) / slug → internal line-viewer URL (the vendor's live microsite)
let VENDOR_ITEM = new Map(); // slugify(vendor) / slug → { url, handles:Set } — the microsite's FULL handle set;
@@ -224,6 +239,25 @@ async function loadStock() {
STOCK = map;
}
+// FileMaker WALLPAPER mirror (dw_unified.filemaker_wallpaper, written by
+// scripts/fm-wallpaper-sync.mjs). FMPro is Steve's SOURCE OF TRUTH: "is this live?"
+// (Date Discontinued), the authoritative mfr number, width + specs, and the source for
+// the FileMaker→Shopify push. Loaded into RAM on the snapshot cycle; a missing/empty
+// table just means no FM enrichment (fully graceful — the grid is unchanged).
+async function loadFileMaker() {
+ const map = new Map();
+ try {
+ const { rows } = await pool.query(
+ `SELECT dw_sku, mfr_number, mfr_number_clean, name_of_pattern, color_of_pattern,
+ title, series, vid, is_discontinued, date_discontinued, width, specs,
+ shopify_handle, dw_retail_price, record_type
+ FROM filemaker_wallpaper`);
+ for (const r of rows) { const k = fmKey(r.dw_sku); if (k) map.set(k, r); }
+ } catch (e) { console.error('filemaker mirror load failed (non-fatal — no FM enrichment):', e.message); }
+ FM = map;
+ FM_STATS.rows = map.size;
+}
+
// Internal line viewer per product line = the vendor's live microsite (astek-landing style).
// Resolved from the crawl artifact so ANY microsite that exists resolves with no code change;
// vendors without a live line viewer degrade gracefully (the control dims).
@@ -272,6 +306,15 @@ function deriveRow(r) {
: (r.status === 'ACTIVE' && r.online_store_published) ? 'Active on site'
: 'Staged';
const sku = r.dw_sku || r.variant_sku || r.sku || null;
+ // FileMaker WALLPAPER join (source of truth). Match on the bare, -SAMPLE-stripped dw_sku.
+ const fmk = fmKey(r.dw_sku || r.variant_sku || r.sku);
+ const fm = (fmk && FM.get(fmk)) || null;
+ if (fm) FM_MATCHED.add(fmk);
+ const shopMfr = r.mfr_sku || null;
+ const fmMfr = fm ? (fm.mfr_number || null) : null;
+ // mfr_mismatch: both sides present AND their normalized tokens disagree → the grid flags it
+ // (the mfr-number verification workflow Steve does by hand in FileMaker's "verification" layouts).
+ const mfrMismatch = !!(fm && fmMfr && shopMfr && cleanMfr(fmMfr) !== cleanMfr(shopMfr));
return {
id: r.id,
sku,
@@ -285,6 +328,16 @@ function deriveRow(r) {
// supplier_name and is rendered only when present (it's blank/inconsistent for some private labels).
mfr_number: r.mfr_sku || null,
mfr_name: r.supplier_name || null,
+ // ── FileMaker source-of-truth enrichment (null when the SKU isn't in the WALLPAPER file) ──
+ fm_present: !!fm,
+ fm_mfr_number: fmMfr, // what FMPro shows as the mfr number
+ fm_discontinued: !!(fm && fm.is_discontinued), // FMPro Date Discontinued present → hidden by default
+ fm_disco_date: fm ? (fm.date_discontinued || null) : null,
+ fm_name: fm ? (fm.name_of_pattern || null) : null,
+ fm_width: fm ? (fm.width || null) : null,
+ fm_specs: fm ? (fm.specs || null) : null,
+ fm_handle: fm ? (fm.shopify_handle || null) : null,
+ mfr_mismatch: mfrMismatch,
status: r.status || null,
lifecycle,
image: r.image_url || null,
@@ -334,6 +387,8 @@ async function loadSnapshot() {
buildVendorViewer();
loadCoverage();
try { await loadStock(); } catch (e) { console.error('stock load failed (non-fatal):', e.message); }
+ try { await loadFileMaker(); } catch (e) { console.error('filemaker load failed (non-fatal):', e.message); }
+ FM_MATCHED = new Set(); // repopulated by deriveRow as it joins each row this cycle
const res = await pool.query(await snapSql());
ROWS = res.rows
.filter((r) => !BANNED.test(r.vendor || '') && !BANNED.test(r.title || ''))
@@ -342,10 +397,14 @@ async function loadSnapshot() {
// plus the extra fields internal consumers (photo + substitute apps) need but the public API omits:
// product_id (numeric, for Shopify photo push), handle (for product-page URLs), tags (for material/
// color/style re-derivation). Archived excluded per the internal-tools scope.
+ // INTERNAL feed also honors FMPro's discontinued truth: a SKU FMPro marks discontinued is
+ // withheld from substitute + photo (they must never surface a dead product), same as Archived.
+ let discoHidden = 0;
ROWS_INTERNAL = res.rows
.map((r) => {
const d = deriveRow(r);
if (d.lifecycle === 'Archived') return null;
+ if (d.fm_discontinued) { discoHidden++; return null; }
const { hay, ...rest } = d;
const pid = (String(r.shopify_id || '').match(/(\d+)\s*$/) || [])[1] || null;
return {
@@ -354,8 +413,17 @@ async function loadSnapshot() {
};
})
.filter(Boolean);
+ // FM stats (for the grid header + /healthz). matched/mismatch counted over the PUBLIC rows;
+ // fm_only_live = FM live Masters that never joined a Shopify row (the opt-in ?fm_only=1 set).
+ FM_STATS.matched = ROWS.filter((r) => r.fm_present).length;
+ FM_STATS.mismatch = ROWS.filter((r) => r.mfr_mismatch).length;
+ FM_STATS.discontinued_hidden = discoHidden;
+ let fmOnly = 0;
+ for (const [k, fm] of FM) { if (!fm.is_discontinued && (!fm.record_type || fm.record_type === 'Master') && !FM_MATCHED.has(k)) fmOnly++; }
+ FM_STATS.fm_only_live = fmOnly;
LOADED_AT = new Date().toISOString();
console.log(`snapshot: ${ROWS.length.toLocaleString()} public + ${ROWS_INTERNAL.length.toLocaleString()} internal products in ${Date.now() - t0}ms (stock ${STOCK.size.toLocaleString()} · viewers ${VENDOR_VIEWER.size})`);
+ console.log(` filemaker: ${FM_STATS.rows.toLocaleString()} mirror rows · ${FM_STATS.matched.toLocaleString()} joined · ${FM_STATS.mismatch.toLocaleString()} mfr# mismatches · ${discoHidden.toLocaleString()} disco-hidden · ${fmOnly.toLocaleString()} FM-only live`);
}
// ── filtering / sorting (japan-viewer pattern) ──────────────────────────────────
@@ -367,6 +435,10 @@ function parseFilters(u) {
series: csv('series'),
colors: csv('colors'), styles: csv('styles'), materials: csv('materials'),
prices: csv('prices'), images: csv('images'),
+ // FileMaker verification views: fm_disco=1 shows the FMPro-discontinued rows normally hidden;
+ // mismatch=1 narrows to rows whose FMPro mfr# disagrees with Shopify's (the audit set).
+ fmDisco: u.searchParams.get('fm_disco') === '1',
+ mfrMismatch: u.searchParams.get('mismatch') === '1',
};
}
// Default scope = EVERYTHING EXCEPT ARCHIVED. all.dw is the INTERNAL "find any item in
@@ -379,6 +451,11 @@ function applyFilters(rows, f, opts = {}) {
out = f.lifecycles.size ? out.filter((r) => f.lifecycles.has(r.lifecycle))
: out.filter((r) => r.lifecycle !== 'Archived');
}
+ // FMPro is the source of truth for "discontinued": hide matched rows FMPro marks with a
+ // Date Discontinued (union — rows not in the WALLPAPER file have fm_discontinued=false and
+ // are unaffected). The verification view (?fm_disco=1) opts them back in.
+ if (!f.fmDisco) out = out.filter((r) => !r.fm_discontinued);
+ if (f.mfrMismatch) out = out.filter((r) => r.mfr_mismatch);
if (f.q) {
// word-START match per term so "red" hits "Red"/"redwood" but not "weatheRED"/"bordeRED"
const terms = f.q.split(/\s+/).filter(Boolean)
@@ -515,6 +592,60 @@ function runLiveScrape(sku, adapterKey, catalogTable) {
});
}
+// ── FileMaker → Shopify push (name/mfr/width/all specs) ──────────────────────────
+// Spawns scripts/shopify-push.mjs for ONE SKU (same worker pattern as live-scrape). DRY-RUN
+// by default — returns the FM→Shopify diff. A live write requires BOTH ?commit=1 on the request
+// AND SHOPIFY_PUSH_ENABLED=1 in the server env (the Steve-gated switch the worker re-checks).
+const PUSH_WORKER = path.join(__dirname, 'scripts', 'shopify-push.mjs');
+const PUSH_ENABLED = process.env.SHOPIFY_PUSH_ENABLED === '1';
+function runPush(sku, commit) {
+ return new Promise((resolve) => {
+ const args = [PUSH_WORKER, sku];
+ if (commit) args.push('--commit');
+ execFile('node', args, { timeout: 90 * 1000, maxBuffer: 4 * 1024 * 1024, env: process.env }, (err, stdout) => {
+ let parsed = null;
+ try { parsed = JSON.parse((stdout || '').trim().split('\n').filter(Boolean).pop()); } catch { /* no JSON */ }
+ if (!parsed) return resolve({ ok: false, sku, reason: err ? ('push worker ' + (err.killed ? 'timed out' : 'error')) : 'no result from push worker' });
+ resolve(parsed);
+ });
+ });
+}
+
+// POST /api/shopify-push?sku=<sku>[&commit=1] — one product. Dry-run unless commit=1 AND server-enabled.
+async function handleShopifyPush(req, res, u) {
+ const sku = (u.searchParams.get('sku') || '').trim();
+ const commit = u.searchParams.get('commit') === '1';
+ if (!sku) return sendJSON(res, 400, { ok: false, reason: 'sku required' });
+ if (commit && !PUSH_ENABLED) return sendJSON(res, 200, { ok: false, sku, blocked: true,
+ reason: 'live writes are gated — SHOPIFY_PUSH_ENABLED is off on this server (Steve-gated switch)' });
+ const result = await runPush(sku, commit && PUSH_ENABLED);
+ return sendJSON(res, 200, { ...result, push_enabled: PUSH_ENABLED });
+}
+
+// POST /api/shopify-push-bulk body { skus:[...] } — dry-run diff over a capped batch (≤100),
+// aggregated. Live commit is intentionally NOT offered here — bulk is a review report only.
+function readBody(req, cap = 1e6) {
+ return new Promise((resolve) => { let b = ''; req.on('data', (c) => { b += c; if (b.length > cap) req.destroy(); }); req.on('end', () => resolve(b)); req.on('error', () => resolve('')); });
+}
+async function handleShopifyPushBulk(req, res) {
+ const body = await readBody(req);
+ let skus = [];
+ try { skus = (JSON.parse(body || '{}').skus || []).map((s) => String(s).trim()).filter(Boolean); } catch { /* bad body */ }
+ skus = [...new Set(skus)].slice(0, 100);
+ if (!skus.length) return sendJSON(res, 400, { ok: false, reason: 'skus[] required (≤100 per call)' });
+ // limited concurrency so we don't hammer the Shopify Admin API
+ const results = []; let i = 0;
+ async function worker() { while (i < skus.length) { const k = skus[i++]; results.push(await runPush(k, false)); } }
+ await Promise.all([worker(), worker(), worker()]);
+ const withChanges = results.filter((r) => r.ok && r.change_count > 0);
+ const totalChanges = withChanges.reduce((n, r) => n + (r.change_count || 0), 0);
+ return sendJSON(res, 200, {
+ ok: true, requested: skus.length, evaluated: results.length,
+ products_with_changes: withChanges.length, total_field_changes: totalChanges,
+ push_enabled: PUSH_ENABLED, results,
+ });
+}
+
async function handleLiveStock(req, res, u) {
const sku = (u.searchParams.get('sku') || '').trim();
const wantLive = u.searchParams.get('live') === '1';
@@ -591,6 +722,96 @@ async function handleLiveStock(req, res, u) {
return sendJSON(res, 200, result);
}
+// ── /api/discontinued?sku=<sku> — resolve a dead/archived SKU + link its live successor ──
+// Ported from ~/.claude/skills/substitutefinder/find.py (resolve_successor + target query).
+// Given ANY sku (dash-insensitive, -SAMPLE-tolerant), returns:
+// { found, sku, title, status, discontinued, successor:{sku,title,handle,url}|null }
+// This queries the DB directly (NOT the in-RAM snapshot) so it can see DELETED_FROM_SHOPIFY /
+// ARCHIVED rows the snapshot's public grid hides. Leak-safe: successor comes from a live ACTIVE
+// product (already-clean title). Never emits cost/net/wholesale.
+const SUCC_BASE = 'https://designerwallcoverings.com/products/';
+
+// _succ_name — strip a COPY-OF-<name> mfr code (or a dead title) down to its bare pattern name.
+function succName(s) {
+ s = (s || '').replace(/copy[\s_-]*of[\s_-]*/ig, ''); // drop COPY-OF-
+ s = s.replace(/[\s_-]+\d+$/, ''); // drop trailing -1..-8
+ s = s.replace(/[_-]+/g, ' ').trim(); // dashes/underscores -> spaces
+ return s.replace(/\s*(wall\s?paper|wall\s?covering)\s*$/i, '').trim(); // drop generic suffix
+}
+
+// resolve_successor — a discontinued item's LIVE successor = an ACTIVE product whose name matches
+// the dead item's real pattern. The clue is often a 'COPY-OF-<successor>' mfr code — sometimes on
+// the typed SKU, sometimes only on a SIBLING sharing the same dead title.
+async function resolveSuccessor(mfr, title) {
+ const names = [succName(mfr)];
+ const baseTitle = (title || '').replace(/\s*\|.*$/, '').trim();
+ if (baseTitle.length >= 4) {
+ // harvest COPY-OF-<x> hints from any row sharing this dead title
+ const { rows } = await pool.query(
+ `select coalesce(mfr_sku,'') mfr from shopify_products
+ where title ilike '%'||$1||'%' and mfr_sku ~* 'copy' limit 10`, [baseTitle]);
+ for (const r of rows) names.push(succName(r.mfr));
+ names.push(baseTitle);
+ }
+ const seen = new Set(); const ordered = [];
+ for (let n of names) {
+ n = (n || '').trim();
+ if (n.length >= 4 && !seen.has(n.toLowerCase())) { seen.add(n.toLowerCase()); ordered.push(n); }
+ }
+ for (const name of ordered) {
+ const { rows } = await pool.query(
+ `select dw_sku, coalesce(title,'') title, coalesce(handle,'') handle
+ from shopify_products
+ where lower(status) in ('active','draft') and image_url is not null
+ and dw_sku is not null and dw_sku <> '' and title ilike '%'||$1||'%'
+ order by (title !~* 'memo|sample') desc,
+ (lower(title) like lower($1)||'%') desc, dw_sku
+ limit 1`, [name]);
+ if (rows.length) {
+ const r = rows[0];
+ const handle = (r.handle || '').replace('�', '').trim();
+ return { sku: r.dw_sku, title: (r.title || '').replace(/�/g, '').trim(),
+ handle, url: handle ? SUCC_BASE + handle : '' };
+ }
+ }
+ return null;
+}
+
+async function handleDiscontinued(req, res, u) {
+ const raw = (u.searchParams.get('sku') || '').trim();
+ if (!raw) return sendJSON(res, 400, { found: false, error: 'sku required' });
+ if (!pool) return sendJSON(res, 503, { found: false, error: 'db not ready' });
+ // normalize: strip a trailing -SAMPLE so a typed CHC-216830 also finds its CHC-216830-SAMPLE row.
+ const qbase = raw.replace(/-sample$/i, '');
+ try {
+ // dash/punct-insensitive base match (regexp_replace '[^a-z0-9]' -> ''), plus the -SAMPLE-suffix
+ // catch via the de-dashed PREFIX. Include archived/deleted (this endpoint's whole point).
+ const { rows } = await pool.query(
+ `select dw_sku, coalesce(title,'') title, coalesce(status,'') status,
+ coalesce(handle,'') handle, coalesce(mfr_sku,'') mfr
+ from shopify_products
+ where regexp_replace(lower(dw_sku),'[^a-z0-9]','','g') like regexp_replace(lower($1),'[^a-z0-9]','','g')||'%'
+ or dw_sku ilike $1 or sku ilike $1
+ or handle ilike ('%'||$1||'%') or mfr_sku ilike $1
+ order by (dw_sku ilike $1) desc,
+ (lower(status) in ('active','draft')) desc,
+ (regexp_replace(lower(dw_sku),'[^a-z0-9]','','g') = regexp_replace(lower($1),'[^a-z0-9]','','g')) desc,
+ (lower(status)='archived') desc
+ limit 1`, [qbase]);
+ if (!rows.length) return sendJSON(res, 200, { found: false, sku: raw });
+ const t = rows[0];
+ const title = (t.title || '').replace(/�/g, '').trim();
+ const status = (t.status || '').toUpperCase();
+ // discontinued = not currently sellable (archived OR hard-deleted from Shopify)
+ const discontinued = !['active', 'draft'].includes((t.status || '').trim().toLowerCase());
+ let successor = null;
+ if (discontinued) successor = await resolveSuccessor(t.mfr, t.title);
+ return sendJSON(res, 200, { found: true, sku: t.dw_sku || raw, title, status, discontinued, successor });
+ } catch (e) {
+ return sendJSON(res, 500, { found: false, error: e.message });
+ }
+}
+
// Basic Auth gate (Steve 2026-07-02: all. is published but un/pw-gated).
// Override with BASIC_AUTH=user:pass (or BASIC_AUTH_USER / BASIC_AUTH_PASS) in .env;
// default stays admin:DW2024! so behavior is byte-identical when unset. /healthz open.
@@ -673,11 +894,15 @@ const server = http.createServer((req, res) => {
image_state: tally(applyFilters(ROWS, f, { skip: 'image' }), 'image_state'),
price_order: PRICE_ORDER, family_order: FAMILY_ORDER, lifecycle_order: LIFE_ORDER,
total: applyFilters(ROWS, f).length,
+ filemaker: FM_STATS,
}));
return;
}
+ if (u.pathname === '/api/discontinued') { handleDiscontinued(req, res, u); return; }
if (u.pathname === '/api/livestock') { handleLiveStock(req, res, u); return; }
+ if (u.pathname === '/api/shopify-push') { handleShopifyPush(req, res, u); return; }
+ if (u.pathname === '/api/shopify-push-bulk') { handleShopifyPushBulk(req, res); return; }
if (u.pathname === '/api/vendors') { // legacy vendor directory API (vendors.html)
try {
@@ -695,7 +920,7 @@ const server = http.createServer((req, res) => {
if (u.pathname === '/healthz') {
res.writeHead(200, { 'Content-Type': 'application/json' });
- return res.end(JSON.stringify({ ok: ROWS.length > 0, products: ROWS.length, loaded_at: LOADED_AT }));
+ return res.end(JSON.stringify({ ok: ROWS.length > 0, products: ROWS.length, loaded_at: LOADED_AT, filemaker: FM_STATS }));
}
// static, path-traversal-safe
← ecdf294 auto-save: 2026-07-08T10:35:15 (1 files) — scripts/fm-wallpa
·
back to All Designerwallcoverings
·
FileMaker WALLPAPER as source of truth across all./substitut 8ed57a0 →