← back to All Designerwallcoverings
public/index.html
1153 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>All Products — Designer Wallcoverings</title>
<style>
:root { --cols: 6; --fs: 1; --bg:#0d0d0f; --card:#17171b; --line:#2a2a30; --txt:#e8e8ea; --mut:#9a9aa2; --accent:#c8a96a; --rail:230px; }
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--txt); font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
/* ── top bar ── */
header { position:sticky; top:0; z-index:10; background:rgba(13,13,15,.96); backdrop-filter:blur(8px);
border-bottom:1px solid var(--line); padding:12px 20px; display:flex; flex-wrap:wrap; gap:10px 16px; align-items:center; }
h1 { margin:0; font-size:17px; font-weight:600; letter-spacing:.02em; white-space:nowrap; }
h1 small { color:var(--mut); font-weight:400; font-size:12px; margin-left:8px; }
h1 a { color:var(--mut); font-size:11px; font-weight:400; margin-left:10px; text-decoration:none; border-bottom:1px dotted var(--mut); }
.pill { background:var(--accent); color:#1a1407; border-radius:20px; padding:2px 10px; font-size:11.5px; font-weight:700; vertical-align:2px; }
select, input[type=search], input[type=text] { background:var(--card); color:var(--txt); border:1px solid var(--line); border-radius:8px; padding:7px 10px; font-size:13px; }
input[type=search] { flex:1 1 200px; min-width:180px; max-width:420px; }
.bar { display:flex; align-items:center; gap:8px; color:var(--mut); font-size:12px; white-space:nowrap; }
.dirbtn { background:var(--card); color:var(--accent); border:1px solid var(--line); border-radius:8px; padding:7px 10px; cursor:pointer; font-size:13px; }
.viewseg { display:flex; border:1px solid var(--line); border-radius:8px; overflow:hidden; }
.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 ── */
.wrap { display:flex; align-items:flex-start; }
aside { width:var(--rail); flex:0 0 var(--rail); position:sticky; top:59px; max-height:calc(100vh - 59px);
overflow-y:auto; padding:10px 10px 60px; border-right:1px solid var(--line); }
/* Astek-style collapsed accordion sections per field */
details.sec { border:1px solid var(--line); border-radius:7px; margin-bottom:7px; background:var(--card); }
details.sec > summary { list-style:none; cursor:pointer; user-select:none; padding:8px 10px; font-size:10.5px;
letter-spacing:.09em; text-transform:uppercase; color:var(--mut); display:flex; align-items:center; gap:7px; }
details.sec > summary::-webkit-details-marker { display:none; }
details.sec > summary::before { content:'▸'; color:var(--accent); font-size:10px; }
details.sec[open] > summary::before { content:'▾'; }
details.sec > summary .n { margin-left:auto; background:var(--accent); color:#1a1407; border-radius:20px;
padding:0 7px; font-size:10px; font-weight:700; }
.fbody { padding:2px 8px 9px; }
.fitem { display:flex; align-items:center; gap:7px; padding:2.5px 2px; cursor:pointer; font-size:12.5px; color:var(--txt); border-radius:5px; }
.fitem:hover { background:var(--card); }
.fitem .box { width:13px; height:13px; flex:0 0 13px; border:1px solid var(--mut); border-radius:3px; display:inline-flex; align-items:center; justify-content:center; font-size:10px; }
.fitem.on .box { background:var(--accent); border-color:var(--accent); color:#1a1407; font-weight:700; }
.fitem.on { color:var(--accent); }
.fitem .lbl { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.fitem .ct { color:var(--mut); font-size:10.5px; }
.fitem.zero { opacity:.35; }
.more { color:var(--accent); font-size:11.5px; cursor:pointer; padding:3px 2px; display:inline-block; }
.ffind { width:100%; padding:4px 8px !important; font-size:11.5px !important; border-radius:6px !important; margin:2px 0 5px; }
.active { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:4px; }
.afilter { background:#23211a; border:1px solid var(--accent); color:var(--txt); border-radius:20px; padding:3px 9px; font-size:11px; cursor:pointer; }
.afilter b { color:var(--accent); font-weight:600; }
.clearall { background:none; border:1px solid var(--line); color:var(--mut); border-radius:20px; padding:3px 11px; font-size:11px; cursor:pointer; }
main { flex:1; min-width:0; }
/* ── grid view (tight 1px cards, type scale rides --fs from the density slider) ── */
.grid { display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:8px; padding:12px 14px 30px; }
.card { background:var(--card); border:1px solid var(--line); border-radius:4px; overflow:hidden; display:flex; flex-direction:column; }
.thumb { aspect-ratio:1/1; background:#000 center/cover no-repeat; position:relative; }
.thumb.empty { display:flex; align-items:center; justify-content:center; color:#444; font-size:11px; }
.badge { position:absolute; top:6px; left:6px; font-size:10px; font-weight:700; letter-spacing:.04em; padding:2px 7px; border-radius:20px; text-transform:uppercase; }
.badge.life-archived { background:#5a3d3d; color:#e8c9c9; }
.badge.life-staged { background:#3d4a5a; color:#c9dae8; }
.badge.life-active { background:#2f4a34; color:#bfe6c6; }
.meta { padding:calc(7px*var(--fs)) calc(9px*var(--fs)); }
.sku { font-weight:700; font-size:calc(13px*var(--fs)); color:var(--accent); word-break:break-all; }
.ttl { color:var(--txt); font-size:calc(11.5px*var(--fs)); margin-top:3px; max-height:2.8em; overflow:hidden; }
.vt { color:var(--mut); font-size:calc(10.5px*var(--fs)); margin-top:3px; }
/* manufacturer identity chips — mono number + muted supplier name (internal curation aid) */
.mfrn { margin-top:3px; font-size:calc(10px*var(--fs)); color:var(--txt); font-variant-numeric:tabular-nums; }
.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:2px 5px; margin-left:-4px; display:flex; flex-wrap:wrap; gap:3px 8px; }
.mfrn.mismatch .fmv { color:#e0b46a; }
.mfrn.mismatch .dwnum { color:var(--accent); }
.mfrn.mismatch .shopv { color:var(--txt); }
.mfrn.mismatch .dwnum b, .mfrn.mismatch .shopv b, .mfrn.mismatch .fmv b { color:var(--mut); font-weight:600; letter-spacing:.03em; margin-right:1px; }
.mfrn .mmflag { color:#e0b46a; cursor:help; }
/* List-view mfr cell: DW · Shop · FM breakout; amber tint + muted labels on a mismatch */
td.tmfr { font-variant-numeric:tabular-nums; font-size:11px; white-space:nowrap; }
td.tmfr .tfm { color:#7fc98a; }
td.tmfr .tmm { background:#2a1d0c; border-radius:4px; padding:1px 5px; color:#e0b46a; }
td.tmfr .tmm .k { color:var(--mut); font-weight:600; letter-spacing:.02em; }
.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; }
.imgcount { position:absolute; bottom:6px; right:6px; background:rgba(0,0,0,.62); color:#fff; font-size:10px;
font-weight:600; padding:1px 6px; border-radius:20px; letter-spacing:.02em; pointer-events:none; }
/* 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; }
/* "Search the catalog for a similar pattern" → button + inline results grid */
#discoBanner .dbfind { display:inline-block; margin-left:6px; padding:5px 14px; font:600 12.5px -apple-system,system-ui,sans-serif;
color:#ffb3a3; background:#3a1a1a; border:1px solid #c0392b; border-radius:6px; cursor:pointer; letter-spacing:.01em; }
#discoBanner .dbfind:hover { background:#4a2020; border-color:#e74c3c; color:#ffd0c5; }
#discoBanner .dbfind:disabled { opacity:.55; cursor:wait; }
#discoBanner .dbsimhead { margin-top:14px; font-size:12px; font-weight:700; color:#e8b3a8;
text-transform:uppercase; letter-spacing:.08em; }
#discoBanner .dbsimgrid { display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr));
gap:10px; margin-top:8px; }
#discoBanner .dbsimgrid .card { border-color:#4a2626; }
.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) */
.when { margin-top:5px; font-size:calc(10px*var(--fs)); color:var(--mut); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.when b { font-weight:700; letter-spacing:.03em; }
.when.life-active b { color:#7fc98a; } .when.life-staged b { color:#c9dae8; } .when.life-archived b { color:#e0b3b3; }
/* per-card Website pill + grouped "Internal ▾" cluster (Admin / Line Viewer / Check Stock).
Every target resolves to a real link/panel or the control dims — never a dead control. */
.acts { display:flex; flex-wrap:wrap; gap:5px; padding:0 calc(9px*var(--fs)) calc(9px*var(--fs)); margin-top:auto; }
.acts a.web, .acts .intbtn { flex:1; text-align:center; font-size:calc(10.5px*var(--fs)); font-weight:600; padding:4px 5px; border-radius:5px;
text-decoration:none; border:1px solid var(--line); cursor:pointer; white-space:nowrap; letter-spacing:.02em; }
.acts a.web { background:var(--accent); color:#1a1407; border-color:var(--accent); }
.acts a.web:hover { filter:brightness(1.08); }
.acts .intbtn { background:transparent; color:var(--accent); font-family:inherit; }
.acts .intbtn:hover { border-color:var(--accent); }
.acts a.web.disabled { opacity:.3; pointer-events:none; background:transparent; color:var(--mut); border-color:var(--line); }
/* expanding internal panel — inline so nothing is clipped by the card's overflow */
.intpanel { flex-basis:100%; width:100%; display:flex; flex-direction:column; gap:4px; margin-top:5px;
padding:6px; border:1px solid var(--line); border-radius:6px; background:#0f0f13; }
.intpanel[hidden] { display:none; }
.im { display:block; text-align:left; font-size:calc(10.5px*var(--fs)); font-weight:600; padding:4px 7px; border-radius:5px;
text-decoration:none; border:1px solid var(--line); background:var(--card); color:var(--accent); cursor:pointer; font-family:inherit; }
.im:hover { border-color:var(--accent); }
.im.disabled { opacity:.35; pointer-events:none; color:var(--mut); border-color:var(--line); }
/* stored-stock popover ($0 local — never a --live portal scrape) */
.stockpop { margin-top:2px; padding:6px 8px; border-radius:5px; background:#15170f; border:1px solid #34381f; font-size:calc(10.5px*var(--fs)); }
.stockpop[hidden] { display:none; }
.stockpop .sdot.in { color:#7fc98a; } .stockpop .sdot.out { color:#e08b8b; }
.stockpop .sline { color:var(--txt); margin-top:2px; } .stockpop .sline.mut { color:var(--mut); }
/* METERED live-check control + popover (amber = money, distinct from the free green stock) */
.acts .im.livebtn, td.acts-td .im.livebtn { border-color:#4a341a; color:#e0b46a; }
.acts .im.livebtn:hover, td.acts-td .im.livebtn:hover { border-color:#e0b46a; }
.livepop { margin-top:2px; padding:6px 8px; border-radius:5px; background:#1c140c; border:1px solid #4a341a; color:var(--txt); font-size:calc(10.5px*var(--fs)); }
.livepop[hidden] { display:none; }
.livepop .sdot.in { color:#7fc98a; } .livepop .sdot.out { color:#e08b8b; }
.livepop .sline { margin-top:2px; } .livepop .sline.mut { color:var(--mut); }
.livepop .lcost { color:var(--accent); }
.lspin { display:inline-block; animation:lspin 1s linear infinite; }
@keyframes lspin { to { transform:rotate(360deg); } }
/* Email Vendor — the stock & price inquiry EVERY vendor gets (blue = correspondence).
Composes a Gmail DRAFT via George; never auto-sends. */
.acts .im.mailbtn, td.acts-td .im.mailbtn { border-color:#1a2a3a; color:#8ab6e0; }
.acts .im.mailbtn:hover, td.acts-td .im.mailbtn:hover { border-color:#8ab6e0; }
.mailpop { margin-top:2px; padding:7px 8px; border-radius:5px; background:#0c1218; border:1px solid #1a2a3a;
font-size:calc(10.5px*var(--fs)); display:flex; flex-direction:column; gap:4px; }
.mailpop[hidden] { display:none; }
.mailpop label { color:var(--mut); font-size:calc(9.5px*var(--fs)); letter-spacing:.04em; }
.mailpop input, .mailpop textarea { width:100%; background:var(--card); color:var(--txt); border:1px solid var(--line);
border-radius:5px; padding:5px 7px; font:inherit; font-size:calc(10.5px*var(--fs)); }
.mailpop textarea { min-height:120px; resize:vertical; line-height:1.45; }
.mailpop .mrow { display:flex; gap:5px; margin-top:2px; }
.mailpop .mgo { flex:1; padding:5px; border-radius:5px; border:1px solid #1a2a3a; background:transparent; color:#8ab6e0;
font-family:inherit; font-weight:600; cursor:pointer; font-size:calc(10.5px*var(--fs)); }
.mailpop .mgo:hover { border-color:#8ab6e0; }
.mailpop .mgo:disabled { opacity:.5; cursor:wait; }
.mailpop a.mgo { text-align:center; text-decoration:none; }
.mailpop .mstat { font-size:calc(10px*var(--fs)); line-height:1.4; }
.mailpop .mstat.ok { color:#7fc98a; } .mailpop .mstat.err { color:#e08b8b; }
/* smart FACE button — the ONE always-present per-card action, full-width row under Website.
Inherits livebtn amber / mailbtn blue from the rules above; stock chip tints in/out. */
.acts .im.face { flex-basis:100%; text-align:center; }
.im.face.in { color:#7fd0a0; } .im.face.out { color:#e08a8a; }
/* shared popovers (stock/live/mail) now sit at the END of .acts — one set per card,
used by both the face button and the Internal-panel copies */
.acts > .stockpop, .acts > .livepop, .acts > .mailpop { flex-basis:100%; width:100%; }
/* list-view compact link cluster */
td.acts-td { white-space:nowrap; }
td.acts-td a, td.acts-td .intbtn { display:inline-block; font-size:11px; font-weight:600; padding:3px 8px; border-radius:5px; text-decoration:none; border:1px solid var(--line); margin:0 4px 3px 0; background:transparent; color:var(--accent); cursor:pointer; font-family:inherit; }
td.acts-td a.web { background:var(--accent); color:#1a1407; border-color:var(--accent); }
td.acts-td .im { display:inline-block; margin:0 4px 0 0; }
td.acts-td a.disabled, td.acts-td .im.disabled { opacity:.35; pointer-events:none; color:var(--mut); }
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-sku.h-ttl.h-vt.h-mfrn.h-mfrname.h-fac.h-price.h-when .meta { display:none; }
/* ── list view ── */
.listwrap { padding:0 16px 30px; overflow-x:auto; }
table { width:100%; border-collapse:collapse; font-size:12.5px; }
thead th { position:sticky; top:59px; background:#131317; z-index:5; text-align:left; padding:9px 10px; font-size:11px;
letter-spacing:.05em; text-transform:uppercase; color:var(--mut); border-bottom:1px solid var(--line);
cursor:pointer; user-select:none; white-space:nowrap; }
thead th:hover { color:var(--accent); }
thead th.sorted { color:var(--accent); }
tbody td { padding:7px 10px; border-bottom:1px solid #1d1d22; vertical-align:middle; }
tbody tr:hover { background:#141419; }
tbody tr.linked { cursor:pointer; }
td.timg { width:52px; } td.timg div { width:44px; height:44px; border-radius:6px; background:#000 center/cover no-repeat; }
td .sku { font-size:12px; }
td.num { text-align:right; font-variant-numeric:tabular-nums; }
.tfac { color:var(--accent); font-size:11px; }
#sentinel { height:1px; }
#status { text-align:center; color:var(--mut); padding:16px; font-size:13px; }
@media (max-width: 900px) {
#railToggle { display:inline-block; }
aside { position:fixed; left:0; top:59px; bottom:0; background:var(--bg); z-index:20; transform:translateX(-100%); transition:transform .2s; }
aside.open { transform:none; box-shadow:6px 0 30px rgba(0,0,0,.5); }
}
</style>
</head>
<body>
<header>
<h1>All Products <span class="pill" id="pillN">…</span> <small id="sub">loading…</small><a href="/vendors.html">vendor directory →</a><a href="/microsites.html">microsites →</a></h1>
<button class="dirbtn" id="railToggle">☰ Filters</button>
<input type="search" id="q" placeholder="Search anything — red, damask, DWKR, Thibaut…">
<span class="bar">Sort
<select id="sort">
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="updated">Recently updated</option>
<option value="title">Title</option>
<option value="vendor">Vendor</option>
<option value="type">Type</option>
<option value="series">Book / Series</option>
<option value="sku">SKU</option>
<option value="pattern">Pattern</option>
<option value="color">Color (family)</option>
<option value="style">Style</option>
<option value="material">Material</option>
<option value="price">Price</option>
<option value="lifecycle">Lifecycle</option>
</select>
<button class="dirbtn" id="dir" title="Flip sort direction">▲</button>
</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">
<aside id="rail">
<div class="active" id="activeRow"></div>
<details class="sec" data-k="fields"><summary>Card fields<span class="n" id="nFields"></span></summary><div class="fbody" id="fieldsRow"></div></details>
<details class="sec" data-k="vendor"><summary>Vendor<span class="n" id="nVendor"></span></summary><div class="fbody" id="vendorRow"></div></details>
<details class="sec" data-k="type"><summary>Type<span class="n" id="nType"></span></summary><div class="fbody" id="typeRow"></div></details>
<details class="sec" data-k="series"><summary>Book / Series<span class="n" id="nSeries"></span></summary><div class="fbody" id="seriesRow"></div></details>
<details class="sec" data-k="color"><summary>Color<span class="n" id="nColor"></span></summary><div class="fbody" id="colorRow"></div></details>
<details class="sec" data-k="style"><summary>Style<span class="n" id="nStyle"></span></summary><div class="fbody" id="styleRow"></div></details>
<details class="sec" data-k="material"><summary>Material<span class="n" id="nMaterial"></span></summary><div class="fbody" id="matRow"></div></details>
<details class="sec" data-k="price"><summary>Price<span class="n" id="nPrice"></span></summary><div class="fbody" id="priceRow"></div></details>
<details class="sec" data-k="lifecycle"><summary>Lifecycle<span class="n" id="nLifecycle"></span></summary><div class="fbody" id="lifecycleRow"></div></details>
<details class="sec" data-k="image"><summary>Image<span class="n" id="nImage"></span></summary><div class="fbody" id="imgRow"></div></details>
</aside>
<main>
<div id="discoBanner"></div>
<div class="grid" id="grid"></div>
<div class="listwrap" id="listwrap" style="display:none">
<table>
<thead><tr id="cols"></tr></thead>
<tbody id="tbody"></tbody>
</table>
</div>
<div id="sentinel"></div>
<div id="status">loading…</div>
</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');
const statusEl = document.getElementById('status'), subEl = document.getElementById('sub');
const LIMIT = 120;
const loadSet = (k) => { try { return new Set(JSON.parse(localStorage.getItem(k) || '[]')); } catch { return new Set(); } };
const urlq = new URLSearchParams(location.search);
let state = {
sort: urlq.get('sort') || localStorage.getItem('all_sort') || 'newest',
dir: urlq.get('dir') || localStorage.getItem('all_dir') || 'asc',
view: urlq.get('view') || localStorage.getItem('all_view') || 'grid',
q: urlq.get('q') || '',
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)
const rowUI = {
vendorRow: { expand: false, find: '', typeahead: true }, typeRow: { expand: false, find: '', typeahead: true },
seriesRow: { expand: false, find: '', typeahead: true },
colorRow: { expand: false, find: '' }, styleRow: { expand: false, find: '' }, matRow: { expand: false, find: '' },
priceRow: { expand: false, find: '' }, lifecycleRow: { expand: false, find: '' }, imgRow: { expand: false, find: '' },
};
/* ── top-bar wiring ── */
const dens = document.getElementById('dens'), densN = document.getElementById('densN');
dens.value = localStorage.getItem('all_cols') || 6;
const setCols = () => {
document.documentElement.style.setProperty('--cols', dens.value); densN.textContent = dens.value;
// type scale rides the density slider (Astek --fs pattern): 6-across = 1.0, shrinking toward .62 at 20
document.documentElement.style.setProperty('--fs', Math.max(.62, Math.min(1.15, 6 / dens.value)).toFixed(2));
};
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';
d.ontoggle = () => localStorage.setItem('all_sec_' + d.dataset.k, d.open ? '1' : '0');
});
/* 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']];
let hiddenFields = loadSet('all_hidden_fields');
function drawFieldToggles() {
const row = document.getElementById('fieldsRow'); row.innerHTML = '';
FIELD_DEFS.forEach(([k, label]) => {
const on = !hiddenFields.has(k);
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();
};
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';
}
function applyFieldClasses() {
FIELD_DEFS.forEach(([k]) => grid.classList.toggle('h-' + k, hiddenFields.has(k)));
}
applyFieldClasses(); drawFieldToggles();
const sortSel = document.getElementById('sort'), dirBtn = document.getElementById('dir');
sortSel.value = ['price_asc','price_desc'].includes(state.sort) ? 'price' : state.sort;
state.sort = sortSel.value;
const drawDir = () => { dirBtn.textContent = state.dir === 'desc' ? '▼' : '▲'; };
drawDir();
sortSel.onchange = () => { state.sort = sortSel.value; persistSort(); reset(); drawCols(); };
dirBtn.onclick = () => { state.dir = state.dir === 'desc' ? 'asc' : 'desc'; persistSort(); drawDir(); reset(); drawCols(); };
function persistSort() { localStorage.setItem('all_sort', state.sort); localStorage.setItem('all_dir', state.dir); }
const vG = document.getElementById('vGrid'), vL = document.getElementById('vList');
function drawView() {
vG.classList.toggle('on', state.view === 'grid'); vL.classList.toggle('on', state.view === 'list');
grid.style.display = state.view === 'grid' ? '' : 'none';
listwrap.style.display = state.view === 'list' ? '' : 'none';
document.getElementById('densWrap').style.opacity = state.view === 'grid' ? 1 : .35;
}
vG.onclick = () => { state.view = 'grid'; localStorage.setItem('all_view', 'grid'); drawView(); reset(); };
vL.onclick = () => { state.view = 'list'; localStorage.setItem('all_view', 'list'); drawView(); reset(); };
drawView();
const rail = document.getElementById('rail');
document.getElementById('railToggle').onclick = () => rail.classList.toggle('open');
const qEl = document.getElementById('q'); qEl.value = state.q;
let qt; qEl.oninput = (e) => { clearTimeout(qt); qt = setTimeout(() => { state.q = e.target.value.trim(); applyFilterChange(); }, 250); };
/* ── list-view sortable columns: every column sorts, click toggles asc/desc ── */
const COLS = [
{ label: '', key: null }, // thumb
{ label: 'SKU', key: 'sku' }, { label: 'Mfr # (DW·Shop·FM)', key: null }, { label: 'Title', key: 'title' }, { label: 'Vendor', key: 'vendor' },
{ label: 'Type', key: 'type' }, { label: 'Pattern', key: 'pattern' }, { label: 'Color', key: 'color' },
{ label: 'Style', key: 'style' }, { label: 'Material', key: 'material' },
{ label: 'Price', key: 'price', num: true }, { label: 'Lifecycle', key: 'lifecycle' },
{ label: 'Shopify status', key: 'status' }, { label: 'Date', key: 'created' }, { label: 'Links', key: null },
];
function drawCols() {
const tr = document.getElementById('cols'); tr.innerHTML = '';
COLS.forEach((c) => {
const th = document.createElement('th');
if (c.key) {
const on = state.sort === c.key;
th.className = on ? 'sorted' : '';
th.textContent = c.label + (on ? (state.dir === 'desc' ? ' ▼' : ' ▲') : '');
th.onclick = () => {
if (state.sort === c.key) state.dir = state.dir === 'desc' ? 'asc' : 'desc';
else { state.sort = c.key; state.dir = 'asc'; }
sortSel.value = c.key; persistSort(); drawDir(); drawCols(); reset();
};
} else th.textContent = c.label;
tr.appendChild(th);
});
}
drawCols();
/* ── filters (shared with facets API) ── */
const SETS = {
vendors: 'all_vendors', types: 'all_types', series: 'all_series', lifecycles: 'all_lifecycles', colors: 'all_colors',
styles: 'all_styles', materials: 'all_materials', prices: 'all_prices', images: 'all_images',
};
function persistFacets() { for (const [k, key] of Object.entries(SETS)) localStorage.setItem(key, JSON.stringify([...state[k]])); }
function facetParams(extra = {}) {
return new URLSearchParams({
sort: state.sort, dir: state.dir, q: state.q,
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(','),
...(state.mismatch ? { mismatch: '1' } : {}), ...(state.fmDisco ? { fm_disco: '1' } : {}), ...extra,
});
}
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 —<button type="button" class="dbfind" id="dbFindBtn">Search the catalog for a similar pattern</button></div><div id="discoSim"></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';
const fb = discoBanner.querySelector('#dbFindBtn');
if (fb) fb.onclick = () => loadSimilar(d.sku);
}
} catch (e) { /* endpoint degrades silently — grid still works */ }
}
// "Search the catalog" button → /api/similar scores live products against the dead SKU's
// attributes (material/style/color/type/vendor/title tokens) and the matches render below
// the banner with the SAME card renderer as the main grid (hover-gallery, actions, links).
async function loadSimilar(sku) {
const box = discoBanner.querySelector('#discoSim');
const btn = discoBanner.querySelector('#dbFindBtn');
if (!box) return;
if (btn) { btn.disabled = true; btn.textContent = 'searching…'; }
try {
const res = await fetch('/api/similar?sku=' + encodeURIComponent(sku) + '&limit=12');
const d = await res.json();
box.innerHTML = '';
if (!d.rows || !d.rows.length) {
box.innerHTML = `<div class="dbnone">No similar live patterns found.</div>`;
} else {
const h = document.createElement('div'); h.className = 'dbsimhead';
h.textContent = `Similar live patterns (${d.rows.length})`; box.appendChild(h);
const g = document.createElement('div'); g.className = 'dbsimgrid'; box.appendChild(g);
for (const r of d.rows) g.appendChild(card(r));
}
if (btn) btn.remove(); // one-shot — results replace the call-to-action
} catch (e) {
if (btn) { btn.disabled = false; btn.textContent = 'Search the catalog for a similar pattern'; }
box.innerHTML = `<div class="dbnone">Similarity search unavailable — try again.</div>`;
}
}
function applyFilterChange() { persistFacets(); refreshFacets(); reset(); }
function toggleSet(set, val) { set.has(val) ? set.delete(val) : set.add(val); applyFilterChange(); }
/* Amazon-style vertical facet list: checkbox rows + counts, type-ahead on big rows,
See-more expander, selected values always visible. */
function facetList(rowId, counts, set, opts = {}) {
const row = document.getElementById(rowId); row.innerHTML = '';
const ui = rowUI[rowId];
if (ui.typeahead) {
const find = document.createElement('input');
find.type = 'text'; find.className = 'ffind'; find.placeholder = 'find…'; find.value = ui.find;
find.oninput = () => { ui.find = find.value; renderFacets(LAST_FACETS); const f2 = document.querySelector(`#${rowId} .ffind`); if (f2) { f2.focus(); f2.setSelectionRange(f2.value.length, f2.value.length); } };
row.appendChild(find);
}
let entries = Object.entries(counts || {});
entries = opts.order
? entries.sort((a, b) => opts.order.indexOf(a[0]) - opts.order.indexOf(b[0]))
: opts.byName
? entries.sort((a, b) => a[0].localeCompare(b[0], undefined, { sensitivity: 'base' }))
: entries.sort((a, b) => b[1] - a[1]);
if (ui.find) entries = entries.filter(([v]) => v.toLowerCase().includes(ui.find.toLowerCase()));
const CAP = opts.cap || 8;
let shown = entries, hidden = 0;
if (!ui.expand && entries.length > CAP) { shown = entries.slice(0, CAP); hidden = entries.length - CAP; }
const shownVals = new Set(shown.map(([v]) => v));
for (const v of set) if (!shownVals.has(v)) { const e = entries.find(([x]) => x === v); shown.push(e || [v, 0]); }
if (!shown.length) { const s = document.createElement('div'); s.style.cssText = 'font-size:10px;color:#6a6a72;padding:2px'; s.textContent = 'none'; row.appendChild(s); return; }
shown.forEach(([val, ct]) => {
const el = document.createElement('div');
el.className = 'fitem' + (set.has(val) ? ' on' : '') + (ct ? '' : ' zero');
el.innerHTML = `<span class="box">${set.has(val) ? '✓' : ''}</span><span class="lbl" title="${val.replace(/"/g, '"')}">${val}</span><span class="ct">${ct.toLocaleString()}</span>`;
el.onclick = () => toggleSet(set, val);
row.appendChild(el);
});
if (hidden > 0) {
const el = document.createElement('span'); el.className = 'more'; el.textContent = `See ${hidden.toLocaleString()} more ▾`;
el.onclick = () => { ui.expand = true; renderFacets(LAST_FACETS); };
row.appendChild(el);
} else if (ui.expand && entries.length > CAP) {
const el = document.createElement('span'); el.className = 'more'; el.textContent = 'See less ▴';
el.onclick = () => { ui.expand = false; renderFacets(LAST_FACETS); };
row.appendChild(el);
}
}
let LAST_FACETS = {};
function renderFacets(f) {
LAST_FACETS = f;
facetList('vendorRow', f.vendor, state.vendors, { cap: 10, byName: true });
facetList('typeRow', f.type, state.types, { cap: 10 });
facetList('seriesRow', f.series, state.series, { cap: 12 });
facetList('colorRow', f.color, state.colors, { order: f.family_order, cap: 12 });
facetList('styleRow', f.style, state.styles, { cap: 10 });
facetList('matRow', f.material, state.materials, { cap: 10 });
facetList('priceRow', f.price_band, state.prices, { order: f.price_order, cap: 8 });
facetList('lifecycleRow', f.lifecycle, state.lifecycles, { order: f.lifecycle_order, cap: 6 });
facetList('imgRow', f.image_state, state.images, { cap: 4 });
renderActive();
}
const SECN = { Vendor: 'nVendor', Type: 'nType', Book: 'nSeries', Color: 'nColor', Style: 'nStyle', Material: 'nMaterial', Price: 'nPrice', Lifecycle: 'nLifecycle', Image: 'nImage' };
function renderActive() {
const row = document.getElementById('activeRow'); row.innerHTML = '';
const dims = [['Vendor', state.vendors], ['Type', state.types], ['Book', state.series], ['Color', state.colors], ['Style', state.styles],
['Material', state.materials], ['Price', state.prices], ['Lifecycle', state.lifecycles], ['Image', state.images]];
for (const [label, set] of dims) { // selection-count badge on each collapsed section header
const b = document.getElementById(SECN[label]);
if (b) { b.textContent = set.size || ''; b.style.display = set.size ? '' : 'none'; }
}
let n = 0;
for (const [label, set] of dims) {
set.forEach((v) => {
n++;
const el = document.createElement('span'); el.className = 'afilter';
el.innerHTML = `<b>${label}:</b> ${v} ✕`;
el.onclick = () => { set.delete(v); applyFilterChange(); };
row.appendChild(el);
});
}
if (n) {
const c = document.createElement('button'); c.className = 'clearall'; c.textContent = 'Clear all';
c.onclick = () => { dims.forEach(([, s]) => s.clear()); applyFilterChange(); };
row.appendChild(c);
}
}
async function refreshFacets() {
try {
const res = await fetch('/api/facets?' + facetParams());
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".
// CSS-safe class from the first word → life-active / life-staged / life-archived.
const lifeClass = (r) => 'life-' + (r.lifecycle || '').split(' ')[0].toLowerCase();
function fmtDate(ms) {
if (!ms) return '';
return new Date(ms).toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
}
function whenChip(r) { // returns { html, textOnly } for grid card / list cell
const label = r.lifecycle || '';
const d = fmtDate(r.created);
if (!d && !label) return { html: '', text: '' };
const iso = r.created ? new Date(r.created).toISOString() : '';
const upd = r.updated && r.updated !== r.created ? ` · updated ${fmtDate(r.updated)}` : '';
return {
html: `<div class="when ${lifeClass(r)}" title="${iso}${upd ? upd.replace(/"/g, '"') : ''}">🕓 <b>${label}</b> ${d}</div>`,
text: `${label} ${d}`.trim(),
};
}
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
// Stored-stock popover — the FREE /livestock snapshot ($0 local). Public-safe availability
// only (in stock / qty / lead / as-of); NEVER a metered --live portal scrape on render.
function stockPopHTML(st) {
if (!st) return '';
const dot = st.in_stock
? '<span class="sdot in">●</span> In stock'
: '<span class="sdot out">●</span> Out of stock';
const bits = [];
if (st.quantity != null && st.quantity !== '') bits.push('Qty ' + esc(st.quantity));
if (st.lead_time) bits.push('Lead ' + esc(st.lead_time));
const when = st.as_of ? 'as of ' + esc(st.as_of) : 'stored snapshot';
const src = st.source ? ' · ' + esc(st.source) : '';
return `<div class="stockpop" hidden>${dot}${bits.length ? '<div class="sline">' + bits.join(' · ') + '</div>' : ''}<div class="sline mut">${when}${src}</div></div>`;
}
// Website pill (unchanged) + a compact grouped "Internal ▾" cluster: Admin, Line Viewer,
// Check Stock. Every target resolves to a real link/panel or dims — never a dead control.
function actButtonsHTML(r) {
const web = r.url
? `<a class="web" href="${r.url}" target="_blank" rel="noopener noreferrer">Website</a>`
: `<a class="web disabled" title="Not live on the storefront yet">Website</a>`;
const admin = r.internal_url
? `<a class="im" href="${r.internal_url}" target="_blank" rel="noopener noreferrer">Shopify Admin ↗</a>`
: `<a class="im disabled" title="No Shopify admin id for this SKU">Shopify Admin</a>`;
const viewer = r.line_viewer_url
? `<a class="im" href="${r.line_viewer_url}" target="_blank" rel="noopener noreferrer">Line Viewer ↗</a>`
: `<a class="im disabled" title="No internal line viewer for this line yet">Line Viewer</a>`;
// This Item = the internal microsite deep-linked to THIS product's page (one item, not the whole line).
const item = r.internal_product_url
? `<a class="im" href="${r.internal_product_url}" target="_blank" rel="noopener noreferrer">This Item ↗</a>`
: `<a class="im disabled" title="No internal product page for this SKU yet">This Item</a>`;
// Check Stock = FREE stored snapshot ($0, local). Check Live = METERED real-time portal
// login+scrape (only enabled for vendors with a wired live adapter; dims otherwise).
const stock = r.stock
? `<button class="im stockbtn" type="button">Check Stock</button>`
: `<button class="im disabled" type="button" title="No stored stock snapshot — run /livestock ${esc(r.sku || '')}">Check Stock</button>`;
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>`;
// 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>`
: '';
// Email Vendor = the stock & price path EVERY vendor gets (Steve 2026-07-15: no dead-end
// buttons — live_capable vendors scrape, everyone can be emailed; also a confirmation path
// for the live ones). Opens a compose panel; "Create Gmail draft" NEVER auto-sends.
const mail = `<button class="im mailbtn" type="button" data-sku="${esc(r.sku || '')}" data-vendor="${esc(r.vendor || '')}" data-mfr="${esc(r.mfr_number || '')}" data-pat="${esc(r.title || r.pattern || '')}" title="Compose a stock & price inquiry to the vendor rep — saves a Gmail DRAFT, never auto-sends">Email Vendor ✉</button>`;
// Smart FACE button — the ONE always-present per-card action (DTD verdict A, 2026-07-15).
// Priority: live check (tier-labeled with its real cost) → stored-stock chip → Email Vendor.
// Reuses livebtn / stockbtn / mailbtn classes so the delegated handleActsClick routes it
// with NO new event wiring; the popovers below are SHARED with the Internal-panel copies.
let face;
if (r.live_capable) {
const label = r.live_tier === 'DB_AUTHORITATIVE' ? 'Price ⚡ $0 · instant'
: r.live_tier === 'PUBLIC_STOCK' ? 'Check Live · ~$0.03'
: 'Check Live · ~$0.15–0.30';
const hint = r.live_tier === 'DB_AUTHORITATIVE'
? 'Instant authoritative price from the vendor price file — price only, availability not checked'
: (r.live_reason || 'Live vendor check');
face = `<button class="im livebtn face" type="button" data-sku="${esc(r.sku || '')}" title="${esc(hint)} · result cached 10 min">${label}</button>`;
} else if (r.stock) {
const inS = !!r.stock.in_stock;
face = `<button class="im stockbtn face ${inS ? 'in' : 'out'}" type="button" title="stored snapshot${r.stock.as_of ? ' · as of ' + esc(r.stock.as_of) : ''}">Stock ${inS ? '✓' : '✗'} (stored)</button>`;
} else {
face = `<button class="im mailbtn face" type="button" data-sku="${esc(r.sku || '')}" data-vendor="${esc(r.vendor || '')}" data-mfr="${esc(r.mfr_number || '')}" data-pat="${esc(r.title || r.pattern || '')}" title="Compose a stock & price inquiry to the vendor rep — saves a Gmail DRAFT, never auto-sends">Email Vendor ✉</button>`;
}
// Shared popovers (stock / live / mail) sit at the END of the .acts container — ONE set per
// card, resolved by closest('.acts, td.acts-td') from face AND panel buttons alike.
return `${web}${face}<button class="intbtn" type="button">Internal ▾</button><div class="intpanel" hidden>${admin}${viewer}${item}${stock}${live}${mail}${push}</div>${stockPopHTML(r.stock)}<div class="livepop" hidden></div><div class="mailpop" hidden></div>`;
}
// Delegated within an .acts container (which stops propagation so the card's own
// open-website click never fires): toggle the Internal panel + the stock popover.
function handleActsClick(e) {
const intbtn = e.target.closest('.intbtn');
if (intbtn) { const p = intbtn.parentElement.querySelector('.intpanel'); if (p) { p.hidden = !p.hidden; intbtn.textContent = p.hidden ? 'Internal ▾' : 'Internal ▴'; } return; }
const sb = e.target.closest('.stockbtn');
if (sb) { const pop = sb.closest('.acts, td.acts-td').querySelector('.stockpop'); if (pop) pop.hidden = !pop.hidden; return; }
const lb = e.target.closest('.livebtn');
if (lb) { fireLiveCheck(lb); return; }
const mb = e.target.closest('.mailbtn');
if (mb) { toggleMailPop(mb); 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).
// Shows a spinner, then the live result + the ACTUAL $ cost (or "cached · $0") inline.
const fmtWhen = (s) => { const t = Date.parse(s); return isNaN(t) ? String(s || '') : fmtDate(t); };
function renderLive(pop, d) {
if (d.rate_limited) { pop.innerHTML = `<span class="sdot out">●</span> ${esc(d.reason || 'rate limited')}${d.retry_after_s ? ` · retry in ${d.retry_after_s}s` : ''}`; return; }
if (d.live_available === false) { pop.innerHTML = `<span class="sline mut">🔒 ${esc(d.reason || 'live check unavailable for this vendor')}</span>`; return; }
if (!d.ok) { pop.innerHTML = `<span class="sdot out">●</span> ${esc(d.reason || 'live scrape failed')}${d.cost_usd ? ` · <b class="lcost">$${Number(d.cost_usd).toFixed(2)} live</b>` : ''}`; return; }
// DB_AUTHORITATIVE returns a price with no availability read — lead with the price,
// never "Availability: unknown" (frontend iteration 2 of the Phase-1 plan).
if (d.in_stock == null && d.price != null) {
const pcost = d.cached ? 'cached · $0' : '$' + Number(d.cost_usd || 0).toFixed(2);
// Price-file result, NOT an inventory read — neutral dot + explicit disclosure so the
// user never mistakes "we have an authoritative price" for "it's in stock".
pop.innerHTML = `<span class="sdot">●</span> Authoritative price <b>$${Number(d.price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</b><div class="sline mut">availability unknown — price file only · use Email Vendor to confirm stock</div><div class="sline mut">as of ${esc(fmtWhen(d.as_of))} · <b class="lcost">${pcost}</b></div>`;
return;
}
const dot = d.in_stock === true ? '<span class="sdot in">●</span> In stock (live)'
: d.in_stock === false ? '<span class="sdot out">●</span> Out of stock (live)'
: `<span class="sdot">●</span> Availability: ${esc(d.availability || 'unknown')}`;
const bits = [];
if (d.lead_time) bits.push('Lead ' + esc(d.lead_time));
if (d.price != null) bits.push('$' + Number(d.price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }));
const cost = d.cached ? 'cached · $0' : ('$' + Number(d.cost_usd || 0).toFixed(2) + ' live');
const when = (d.cached ? 'cached · as of ' : 'fresh · ') + esc(fmtWhen(d.as_of));
pop.innerHTML = `${dot}${bits.length ? '<div class="sline">' + bits.join(' · ') + '</div>' : ''}<div class="sline mut">${when} · <b class="lcost">${cost}</b></div>`;
}
// Terminal responses render immediately (synchronous DB tier + rate-limit/cap/dim). A status:'pending'
// means the slow (Browserbase) scrape is running in the background — the initiating request no longer
// blocks on it (so it never 504s the proxy), so we poll /api/livestock…&poll=1 until status:'done'.
const LIVE_POLL_MAX = 30; // ~90s of polling at the default 3s cadence
function fireLiveCheck(lb) {
if (lb.dataset.busy === '1') return; // client debounce — no double-fire
const pop = lb.closest('.acts, td.acts-td').querySelector('.livepop');
const sku = lb.dataset.sku;
pop.hidden = false; pop.className = 'livepop';
pop.innerHTML = '<span class="lspin">◌</span> Checking live vendor portal…';
lb.dataset.busy = '1'; const orig = lb.textContent; lb.textContent = 'Checking…';
const done = () => { lb.dataset.busy = ''; lb.textContent = orig; };
const tooLong = () => { pop.innerHTML = '<span class="sdot out">●</span> live check took too long — use Email Vendor'; done(); };
const poll = (n, afterMs) => {
if (n >= LIVE_POLL_MAX) return tooLong();
setTimeout(() => {
fetch('/api/livestock?sku=' + encodeURIComponent(sku) + '&live=1&poll=1')
.then((r) => r.json()).then((d) => {
if (d.status === 'pending') return poll(n + 1, d.poll_after_ms || 3000);
if (d.status === 'gone') return tooLong();
renderLive(pop, d); done(); // status:'done' (or any terminal shape)
})
.catch(() => { pop.innerHTML = '<span class="sdot out">●</span> Live check failed — try again'; done(); });
}, afterMs || 3000);
};
fetch('/api/livestock?sku=' + encodeURIComponent(sku) + '&live=1')
.then((r) => r.json()).then((d) => {
if (d.status === 'pending') return poll(0, d.poll_after_ms || 3000);
renderLive(pop, d); done(); // terminal on first hit (DB tier / rate-limit / cap / dim)
})
.catch(() => { pop.innerHTML = '<span class="sdot out">●</span> Live check failed — try again'; done(); });
}
// ── Email Vendor — stock & price inquiry compose (EVERY vendor; Gmail DRAFT only) ──────
// To: pre-fills from /api/vendor-contact (and is REMEMBERED on submit via the server upsert,
// so contact coverage grows as it's used). Professional trade-tone body asks the VENDOR for
// availability / lead time / THEIR current net-trade price — our cost data never appears.
// House signature block = the one dw-pitch-followup ships (address + 1-888-373-4564).
const INQ_SIGNATURE = 'Thank you,\n\nDesigner Wallcoverings\nsteve@designerwallcoverings.com · 1-888-373-4564\n15442 Ventura Blvd, #102, Sherman Oaks, CA 91403';
function inquiryDefaults(d) { // d = { vendor, sku, mfr, pat } (all strings, possibly empty)
const ref = [d.mfr || d.sku, d.pat].filter(Boolean).join(' · ') || d.vendor || '';
const subject = `Stock & price check — ${ref} (Designer Wallcoverings)`;
const lines = ['Hello,', '',
'This is Designer Wallcoverings (trade account) — could you check current stock and pricing on the following item for us?', ''];
if (d.pat) lines.push(` Pattern / colorway: ${d.pat}`);
if (d.mfr) lines.push(` Mfr SKU: ${d.mfr}`);
if (d.sku && d.sku !== d.mfr) lines.push(` Our ref: ${d.sku}`);
if (d.vendor) lines.push(` Line: ${d.vendor}`);
lines.push('', 'Specifically:',
' • Current stock / availability',
' • Lead time if not in stock',
' • Current net / trade price',
'', INQ_SIGNATURE);
return { subject, body: lines.join('\n') };
}
const CONTACT_CACHE = {}; // vendor → {email} (session cache so repeat opens don't refetch)
function toggleMailPop(mb) {
const pop = mb.closest('.acts, td.acts-td').querySelector('.mailpop');
if (!pop) return;
if (!pop.hidden) { pop.hidden = true; return; }
pop.hidden = false;
if (!pop.dataset.built) buildMailPop(pop, mb.dataset);
}
async function buildMailPop(pop, d) {
pop.dataset.built = '1';
const def = inquiryDefaults(d);
pop.innerHTML = `
<label>To${d.vendor ? ' — ' + esc(d.vendor) + ' rep' : ''}</label>
<input type="text" class="mto" placeholder="vendor rep email — will be remembered">
<label>Subject</label>
<input type="text" class="msub">
<label>Message</label>
<textarea class="mbody"></textarea>
<div class="mrow"><button type="button" class="mgo mdraft">Create Gmail draft</button><a class="mgo mmailto" target="_blank" rel="noopener noreferrer">Open in mail app</a></div>
<div class="mstat" hidden></div>`;
pop.querySelector('.msub').value = def.subject;
pop.querySelector('.mbody').value = def.body;
const syncMailto = () => {
pop.querySelector('.mmailto').href = 'mailto:' + encodeURIComponent(pop.querySelector('.mto').value.trim())
+ '?subject=' + encodeURIComponent(pop.querySelector('.msub').value)
+ '&body=' + encodeURIComponent(pop.querySelector('.mbody').value);
};
pop.querySelectorAll('input,textarea').forEach((el) => el.addEventListener('input', syncMailto));
syncMailto();
pop.querySelector('.mdraft').addEventListener('click', () => fireInquiry(pop, d));
// pre-fill the remembered rep email for this vendor (grows via the server upsert)
if (d.vendor) {
try {
const c = CONTACT_CACHE[d.vendor]
|| (CONTACT_CACHE[d.vendor] = await (await fetch('/api/vendor-contact?vendor=' + encodeURIComponent(d.vendor))).json());
const to = pop.querySelector('.mto');
if (c && c.email && !to.value) { to.value = c.email; syncMailto(); }
} catch (e) { /* unknown contact — placeholder stands */ }
}
}
async function fireInquiry(pop, d) {
const btn = pop.querySelector('.mdraft'), stat = pop.querySelector('.mstat');
const to = pop.querySelector('.mto').value.trim();
const subject = pop.querySelector('.msub').value.trim();
const body = pop.querySelector('.mbody').value;
stat.hidden = false; stat.className = 'mstat';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to)) { stat.className = 'mstat err'; stat.textContent = 'Enter the vendor rep’s email address first.'; return; }
if (!subject.trim() || !body.trim()) { stat.className = 'mstat err'; stat.textContent = 'Subject and message are required.'; return; }
btn.disabled = true; const orig = btn.textContent; btn.textContent = 'Drafting…';
stat.textContent = '◌ Saving contact + creating Gmail draft…';
try {
const r = await fetch('/api/vendor-inquiry', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vendor: d.vendor || '', to, subject, body, sku: d.sku || '' }),
});
const j = await r.json();
if (j.drafted) {
stat.className = 'mstat ok';
stat.textContent = '✓ draft saved in Gmail (' + (j.account || 'steve-office') + ') — review + send from there. Contact remembered.';
if (d.vendor) CONTACT_CACHE[d.vendor] = { email: to };
} else if (j.ok) {
stat.className = 'mstat err';
stat.textContent = (j.contact_saved ? 'Contact saved, but the ' : 'The ') + 'Gmail draft failed: '
+ (j.reason || 'unknown') + ' — use “Open in mail app” instead.';
if (j.contact_saved && d.vendor) CONTACT_CACHE[d.vendor] = { email: to };
} else {
stat.className = 'mstat err'; stat.textContent = j.reason || 'Request failed — try again.';
}
} catch (e) {
stat.className = 'mstat err'; stat.textContent = 'Request failed — try again.';
} finally { btn.disabled = false; btn.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;
// On a mismatch, put all three identifiers on one line — DW SKU · Shopify mfr · FileMaker mfr —
// so the mfr numbers can be cross-referenced against the DW number without looking back up.
if (mm) {
const dwNum = esc(String(r.sku || '').replace(/-sample$/i, ''));
return `<div class="mfrn mismatch"><span class="dwnum" title="DW SKU number"><b>DW</b> ${dwNum}</span> <span class="shopv"><b>Shop</b> ${shop}</span> <span class="fmv" title="what FileMaker Pro shows as the mfr number"><b>FM</b> ${esc(r.fm_mfr_number)}</span> <span class="mmflag" title="FileMaker and Shopify mfr numbers disagree — verify">⚠</span></div>`;
}
return `<div class="mfrn"><b>Mfr #</b> ${shop} <span class="fmv" title="what FileMaker Pro shows as the mfr number">· FM ${esc(r.fm_mfr_number)}</span></div>`;
}
return r.mfr_number ? `<div class="mfrn"><b>Mfr #</b> ${shop}</div>` : '';
}
// Compact List-view mfr cell — same DW · Shop · FM breakout as the card, one line for the table.
function mfrCell(r) {
const shop = r.mfr_number ? esc(r.mfr_number) : '—';
if (r.fm_present && r.fm_mfr_number) {
const fm = esc(r.fm_mfr_number);
if (r.mfr_mismatch) {
const dw = esc(String(r.sku || '').replace(/-sample$/i, ''));
return `<span class="tmm"><span class="k">DW</span> ${dw} <span class="k">Shop</span> ${shop} <span class="k">FM</span> <b>${fm}</b> ⚠</span>`;
}
return `${shop} <span class="tfm">· FM ${fm}</span>`;
}
return shop;
}
// 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>` : '';
// gallery: hover the thumb to cycle every image (Shopify full image set)
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>`;
const vt = [r.vendor, r.type].filter(Boolean).join(' · ');
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}${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'); }
// hover-cycle the full Shopify gallery on the thumb (no extra requests — urls already in the row)
if (gcount > 1 && r.images && r.images.length > 1) {
const th = el.querySelector('.thumb'); let iv = null, i = 0;
th.addEventListener('mouseenter', () => { iv = setInterval(() => { i = (i + 1) % r.images.length; th.style.backgroundImage = `url('${r.images[i]}')`; }, 700); });
th.addEventListener('mouseleave', () => { clearInterval(iv); i = 0; th.style.backgroundImage = `url('${r.images[0]}')`; });
}
return el;
}
function listRow(r) {
const tr = document.createElement('tr');
const cell = (html, cls) => { const td = document.createElement('td'); if (cls) td.className = cls; td.innerHTML = html; return td; };
tr.appendChild((() => { const td = document.createElement('td'); td.className = 'timg';
td.innerHTML = r.image ? `<div style="background-image:url('${r.image}')"></div>` : '<div></div>'; return td; })());
tr.appendChild(cell(`<span class="sku">${r.sku || '—'}</span>`));
tr.appendChild(cell(mfrCell(r), 'tmfr'));
tr.appendChild(cell((r.title || '').replace(/</g, '<')));
tr.appendChild(cell(r.vendor || ''));
tr.appendChild(cell(r.type || ''));
tr.appendChild(cell(r.pattern || ''));
tr.appendChild(cell(`<span class="tfac">${(r.colors || []).join(', ')}</span>`));
tr.appendChild(cell(`<span class="tfac">${(r.styles || []).join(', ')}</span>`));
tr.appendChild(cell(`<span class="tfac">${(r.materials || []).join(', ')}</span>`));
tr.appendChild(cell(r.price ? '$' + r.price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '', 'num'));
tr.appendChild(cell(r.lifecycle ? `<span class="when ${lifeClass(r)}"><b>${r.lifecycle}</b></span>` : ''));
tr.appendChild(cell(r.status || ''));
tr.appendChild(cell(r.created ? `<span title="${new Date(r.created).toISOString()}">${fmtDate(r.created)}</span>` : ''));
const actTd = cell(actButtonsHTML(r), 'acts-td');
actTd.addEventListener('click', (e) => { e.stopPropagation(); handleActsClick(e); });
tr.appendChild(actTd);
if (r.url) { tr.className = 'linked'; tr.onclick = () => window.open(r.url, '_blank', 'noopener,noreferrer'); }
return tr;
}
async function load() {
if (state.loading || state.done) return;
state.loading = true;
try {
const res = await fetch('/api/products?' + facetParams({ offset: state.offset, limit: LIMIT }));
const data = await res.json();
state.total = data.total;
const frag = document.createDocumentFragment();
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;
document.getElementById('pillN').textContent = data.total.toLocaleString();
subEl.textContent = `of ${data.catalog.toLocaleString()} products · showing ${state.offset.toLocaleString()}`;
statusEl.textContent = data.total === 0 ? '— no matching products —'
: (state.done ? `— end · ${state.total.toLocaleString()} matching —` : 'scroll for more…');
} catch (e) {
statusEl.textContent = 'Load error — check server';
} finally {
state.loading = false;
}
}
// Robust infinite scroll: a "pump" loop keeps loading WHILE the sentinel sits in (or near)
// the viewport. It re-measures the sentinel's geometry each iteration with getBoundingClientRect
// instead of trusting the IntersectionObserver's cached `sentVisible` flag — that flag only
// updates on an intersection TRANSITION, so after a reset() (filter/sort/view/search) that
// empties the grid and reloads a short first page, no transition fires and the stale flag
// (often false when the user changed a facet from near the top) left "scroll for more…"
// permanently stuck until the user manually scrolled. Geometry is always current, so the
// pump now self-sustains on initial load and after every reset regardless of observer timing.
const SENT_MARGIN = 600; // must match the observer rootMargin below
const sentEl = document.getElementById('sentinel');
function sentinelInView() {
const r = sentEl.getBoundingClientRect();
const vh = window.innerHeight || document.documentElement.clientHeight;
return r.top <= vh + SENT_MARGIN && r.bottom >= -SENT_MARGIN;
}
let sentVisible = false, pumping = false;
async function pump() {
if (pumping) return;
pumping = true;
try {
while (!state.done && sentinelInView()) {
const before = state.offset;
await load();
if (state.offset === before) break; // no progress (error/empty) — bail, don't spin
}
} finally { pumping = false; }
}
new IntersectionObserver((es) => { sentVisible = es[0].isIntersecting; if (sentVisible) pump(); }, { rootMargin: SENT_MARGIN + 'px' })
.observe(sentEl);
// Re-pump on window resize too (a wider viewport can reveal the sentinel with no scroll event).
window.addEventListener('resize', () => { if (!state.done) pump(); });
refreshFacets(); load().then(() => pump());
</script>
</body>
</html>