← back to Designer Wallcoverings
Activated viewer: GraphQL feed with essentials audit (priced/image/specs/width) — per-card gap badges, coverage summary bar, gaps-only filter
c14beea559143c8b79631292ccc48cf9e31aa9ea · 2026-06-20 09:53:35 -0700 · Steve
Files touched
A shopify/activated-viewer/audit-result.jsonA shopify/activated-viewer/audit.jsM shopify/activated-viewer/public/index.htmlM shopify/activated-viewer/server.js
Diff
commit c14beea559143c8b79631292ccc48cf9e31aa9ea
Author: Steve <steve@designerwallcoverings.com>
Date: Sat Jun 20 09:53:35 2026 -0700
Activated viewer: GraphQL feed with essentials audit (priced/image/specs/width) — per-card gap badges, coverage summary bar, gaps-only filter
---
shopify/activated-viewer/audit-result.json | 490 +++++++++++++++++++++++++++++
shopify/activated-viewer/audit.js | 65 ++++
shopify/activated-viewer/public/index.html | 83 ++---
shopify/activated-viewer/server.js | 42 ++-
4 files changed, 632 insertions(+), 48 deletions(-)
diff --git a/shopify/activated-viewer/audit-result.json b/shopify/activated-viewer/audit-result.json
new file mode 100644
index 00000000..ed40c6e3
--- /dev/null
+++ b/shopify/activated-viewer/audit-result.json
@@ -0,0 +1,490 @@
+{
+ "n": 1250,
+ "fail": {
+ "priced": [
+ "GRS-32064-sample",
+ "GRS-32004-sample",
+ "PRO-43969-sample",
+ "STR-54844-sample",
+ "PRW-920018-Sample",
+ "PRW-620045-Sample",
+ "STR-54905-sample",
+ "STR-54898-sample",
+ "488-418-sample",
+ "XCD-69430-sample",
+ "488-439-sample",
+ "XG8-66955-sample",
+ "488-427-sample",
+ "XB8-66605-sample",
+ "XE7-66840-sample",
+ "XE7-66831-sample",
+ "XB5-66575-sample",
+ "XB2-66516-sample",
+ "XB5-66569-sample",
+ "XB2-66506-sample",
+ "STR-54887-sample",
+ "XC2-66667-sample",
+ "PRT-47513-sample",
+ "STR-54878-sample",
+ "PRO-43745-sample",
+ "STR-54858-sample",
+ "PRO-43962-sample",
+ "STR-54852-sample",
+ "488-417-sample",
+ "PRO-43756-sample",
+ "PRO-43753-sample",
+ "488-419-sample",
+ "XE7-66827-sample",
+ "PRO-43747-sample",
+ "PRO-43502-sample",
+ "PRO-43643-sample",
+ "GRS-32017-sample",
+ "PRO-43573-sample",
+ "GRS-32041-sample",
+ "DWPX-500092-Sample",
+ "DWPX-500087-Sample",
+ "DWPX-500075-Sample",
+ "DWPX-500069-Sample",
+ "DWPX-500101-Sample",
+ "DWPX-500095-Sample",
+ "DWPX-500051-Sample",
+ "DWPX-500100-Sample",
+ "DWPX-500094-Sample",
+ "DWPX-500066-Sample",
+ "DWPX-500077-Sample",
+ "DWPX-500015-Sample",
+ "DWPX-500060-Sample",
+ "DWPX-500054-Sample",
+ "DWPX-500065-Sample",
+ "DWPX-500093-Sample",
+ "DWPW-430181-sample",
+ "DWPX-500102-Sample",
+ "DWPX-500096-Sample",
+ "DWPX-500076-Sample",
+ "DWPW-430139-sample",
+ "DWPX-500018-Sample",
+ "DWPX-500086-Sample",
+ "DWPX-500064-Sample",
+ "DWPX-500058-Sample",
+ "DWPW-430206-sample",
+ "DWPX-500017-Sample",
+ "DWPX-500068-Sample",
+ "DWPX-500073-Sample",
+ "DWPX-500067-Sample",
+ "DWPW-430160-sample",
+ "DWPX-500061-Sample",
+ "DWPW-430048-sample",
+ "DWPW-430159-sample",
+ "DWPW-430115-sample",
+ "DWPX-500016-Sample"
+ ],
+ "image": [
+ "DWTB-11459"
+ ],
+ "specs": [
+ "DWTB-10170",
+ "DWTB-11459",
+ "GRS-32064-sample",
+ "GRS-32004-sample",
+ "PRO-43969-sample",
+ "STR-54844-sample",
+ "PRW-920018-Sample",
+ "PRW-620045-Sample",
+ "STR-54905-sample",
+ "STR-54898-sample",
+ "488-418-sample",
+ "XCD-69430-sample",
+ "488-439-sample",
+ "XG8-66955-sample",
+ "488-427-sample",
+ "XB8-66605-sample",
+ "XE7-66840-sample",
+ "XE7-66831-sample",
+ "XB5-66575-sample",
+ "XB2-66516-sample",
+ "XB5-66569-sample",
+ "XB2-66506-sample",
+ "STR-54887-sample",
+ "XC2-66667-sample",
+ "PRT-47513-sample",
+ "STR-54878-sample",
+ "PRO-43745-sample",
+ "STR-54858-sample",
+ "PRO-43962-sample",
+ "STR-54852-sample",
+ "488-417-sample",
+ "PRO-43756-sample",
+ "PRO-43753-sample",
+ "488-419-sample",
+ "XE7-66827-sample",
+ "PRO-43747-sample",
+ "PRO-43502-sample",
+ "PRO-43643-sample",
+ "GRS-32017-sample",
+ "PRO-43573-sample",
+ "GRS-32041-sample",
+ "DWPX-500092-Sample",
+ "DWPX-500087-Sample",
+ "DWPX-500075-Sample",
+ "DWPX-500069-Sample",
+ "DWPX-500101-Sample",
+ "DWPX-500095-Sample",
+ "DWPX-500051-Sample",
+ "DWPX-500100-Sample",
+ "DWPX-500094-Sample",
+ "DWPX-500066-Sample",
+ "DWPX-500077-Sample",
+ "DWPX-500015-Sample",
+ "DWPX-500060-Sample",
+ "DWPX-500054-Sample",
+ "DWPX-500065-Sample",
+ "DWPX-500093-Sample",
+ "DWPW-430181-sample",
+ "DWPX-500102-Sample",
+ "DWPX-500096-Sample",
+ "DWPX-500076-Sample",
+ "DWPW-430139-sample",
+ "DWPX-500018-Sample",
+ "DWPX-500086-Sample",
+ "DWPX-500064-Sample",
+ "DWPX-500058-Sample",
+ "DWPW-430206-sample",
+ "DWPX-500017-Sample",
+ "DWPX-500068-Sample",
+ "DWPX-500073-Sample",
+ "DWPX-500067-Sample",
+ "DWPW-430160-sample",
+ "DWPX-500061-Sample",
+ "DWPW-430048-sample",
+ "DWPW-430159-sample",
+ "DWPW-430115-sample",
+ "DWPX-500016-Sample"
+ ],
+ "width": [
+ "W6295-06-OSB",
+ "NCW4597-04-OSB",
+ "DWTB-11293",
+ "DWTB-11294",
+ "DWTB-11295",
+ "DWTB-11296",
+ "DWTB-11297",
+ "DWTB-11298",
+ "DWTB-11299",
+ "DWTB-11300",
+ "DWTB-11301",
+ "DWTB-11302",
+ "DWTB-11303",
+ "DWTB-11304",
+ "DWTB-10922",
+ "DWTB-11067",
+ "DWTB-11068",
+ "DWTB-11069",
+ "DWTB-11070",
+ "DWTB-11071",
+ "DWTB-11072",
+ "DWTB-11073",
+ "DWTB-11074",
+ "DWTB-11075",
+ "DWTB-11076",
+ "DWTB-11077",
+ "DWTB-11078",
+ "DWTB-11079",
+ "DWTB-11080",
+ "DWTB-11081",
+ "DWTB-11082",
+ "DWTB-11083",
+ "DWTB-11153",
+ "DWTB-11154",
+ "DWTB-11155",
+ "DWTB-11156",
+ "DWTB-11157",
+ "DWTB-11158",
+ "DWTB-11159",
+ "DWTB-11160",
+ "DWTB-11161",
+ "DWTB-11162",
+ "DWTB-11163",
+ "DWTB-11164",
+ "DWTB-11165",
+ "DWTB-11166",
+ "DWTB-11167",
+ "DWTB-11168",
+ "DWTB-11169",
+ "DWTB-11170",
+ "DWTB-11171",
+ "DWTB-11172",
+ "DWTB-11173",
+ "DWTB-11174",
+ "DWTB-11175",
+ "DWTB-11176",
+ "DWTB-11177",
+ "DWTB-11178",
+ "DWTB-11179",
+ "DWTB-11180",
+ "DWTB-11181",
+ "DWTB-11182",
+ "DWTB-11183",
+ "DWTB-11184",
+ "DWTB-11185",
+ "DWTB-11186",
+ "DWTB-11187",
+ "DWTB-11188",
+ "DWTB-11189",
+ "DWTB-11190",
+ "DWTB-11191",
+ "DWTB-11192",
+ "DWTB-11193",
+ "DWTB-11194",
+ "DWTB-11084",
+ "DWTB-11085",
+ "DWTB-11086",
+ "DWTB-11087",
+ "DWTB-11088",
+ "DWTB-11089",
+ "DWTB-11090",
+ "DWTB-11091",
+ "DWTB-11092",
+ "DWTB-11093",
+ "DWTB-11094",
+ "DWTB-11095",
+ "DWTB-11096",
+ "DWTB-11097",
+ "DWTB-11098",
+ "DWTB-11099",
+ "DWTB-11100",
+ "DWTB-11101",
+ "DWTB-11375",
+ "DWTB-11376",
+ "DWTB-11377",
+ "DWTB-11378",
+ "DWTB-11379",
+ "DWTB-11380",
+ "DWTB-11381",
+ "DWTB-11382",
+ "DWTB-11383",
+ "DWTB-11384",
+ "DWTB-11385",
+ "DWTB-11386",
+ "DWTB-11387",
+ "DWTB-11388",
+ "DWTB-11389",
+ "DWTB-11390",
+ "DWTB-11391",
+ "DWTB-11392",
+ "DWTB-11393",
+ "DWTB-11394",
+ "DWTB-11395",
+ "DWTB-11396",
+ "DWTB-11397",
+ "DWTB-11398",
+ "DWTB-11399",
+ "DWTB-11400",
+ "DWTB-11401",
+ "DWTB-11402",
+ "DWTB-11403",
+ "DWTB-11404",
+ "DWTB-11405",
+ "DWTB-11406",
+ "DWTB-11407",
+ "DWTB-11408",
+ "DWTB-11409",
+ "DWTB-11410",
+ "DWTB-11411",
+ "DWTB-11412",
+ "DWTB-11413",
+ "DWTB-11414",
+ "DWTB-11415",
+ "DWTB-11416",
+ "DWTB-10148",
+ "DWTB-10149",
+ "DWTB-10150",
+ "DWTB-10151",
+ "DWTB-10152",
+ "DWTB-10153",
+ "DWTB-10154",
+ "DWTB-10155",
+ "DWTB-10156",
+ "DWTB-10157",
+ "DWTB-10158",
+ "DWTB-10159",
+ "DWTB-10160",
+ "DWTB-10161",
+ "DWTB-10162",
+ "DWTB-10163",
+ "DWTB-10164",
+ "DWTB-10165",
+ "DWTB-10166",
+ "DWTB-10167",
+ "DWTB-10168",
+ "DWTB-10169",
+ "DWTB-10170",
+ "DWTB-10171",
+ "DWTB-10172",
+ "DWTB-10173",
+ "DWTB-10174",
+ "DWTB-10175",
+ "DWTB-10176",
+ "DWTB-10177",
+ "DWTB-10178",
+ "DWTB-10179",
+ "DWTB-10180",
+ "DWTB-10181",
+ "DWTB-11102",
+ "DWTB-11103",
+ "DWTB-11104",
+ "DWTB-11105",
+ "DWTB-11106",
+ "DWTB-11107",
+ "DWTB-11108",
+ "DWTB-11109",
+ "DWTB-11110",
+ "DWTB-11111",
+ "DWTB-11112",
+ "DWTB-11113",
+ "DWTB-11114",
+ "DWTB-11115",
+ "DWTB-11116",
+ "DWTB-11117",
+ "DWTB-11118",
+ "DWTB-11455",
+ "DWTB-11456",
+ "DWTB-11457",
+ "DWTB-11458",
+ "DWTB-11459",
+ "DWTB-11460",
+ "DWTB-11461",
+ "DWTB-11462",
+ "DWTB-11463",
+ "DWTB-11464",
+ "DWTB-11465",
+ "DWTB-11466",
+ "DWTB-11467",
+ "DWTB-11468",
+ "DWTB-11469",
+ "DWTB-11470",
+ "DWTB-11471",
+ "DWTB-11472",
+ "DWTB-11473",
+ "DWTB-11474",
+ "DWTB-11475",
+ "DWTB-11476",
+ "DWTB-11477",
+ "DWTB-11478",
+ "DWTB-11479",
+ "DWTB-11480",
+ "DWTB-11481",
+ "DWTB-11482",
+ "DWTB-11483",
+ "DWTB-11484",
+ "DWTB-11485",
+ "DWTB-11486",
+ "DWTB-11487",
+ "DWTB-11488",
+ "DWTB-11489",
+ "DWTB-11490",
+ "DWTB-11491",
+ "DWTB-11492",
+ "DWTB-11119",
+ "DWTB-11120",
+ "DWTB-11121",
+ "DWTB-11122",
+ "DWTB-11123",
+ "DWTB-11124",
+ "DWTB-11125",
+ "DWTB-11126",
+ "DWTB-11127",
+ "DWTB-11128",
+ "DWTB-11129",
+ "DWTB-11130",
+ "DWTB-11131",
+ "DWTB-11132",
+ "DWTB-11133",
+ "DWTB-11134",
+ "GRS-32064-sample",
+ "GRS-32004-sample",
+ "PRO-43969-sample",
+ "STR-54844-sample",
+ "PRW-920018-Sample",
+ "PRW-620045-Sample",
+ "STR-54905-sample",
+ "STR-54898-sample",
+ "488-418-sample",
+ "XCD-69430-sample",
+ "488-439-sample",
+ "XG8-66955-sample",
+ "488-427-sample",
+ "XB8-66605-sample",
+ "XE7-66840-sample",
+ "XE7-66831-sample",
+ "XB5-66575-sample",
+ "XB2-66516-sample",
+ "XB5-66569-sample",
+ "XB2-66506-sample",
+ "STR-54887-sample",
+ "XC2-66667-sample",
+ "PRT-47513-sample",
+ "STR-54878-sample",
+ "PRO-43745-sample",
+ "STR-54858-sample",
+ "PRO-43962-sample",
+ "STR-54852-sample",
+ "488-417-sample",
+ "PRO-43756-sample",
+ "PRO-43753-sample",
+ "488-419-sample",
+ "XE7-66827-sample",
+ "PRO-43747-sample",
+ "PRO-43502-sample",
+ "PRO-43643-sample",
+ "GRS-32017-sample",
+ "PRO-43573-sample",
+ "GRS-32041-sample",
+ "DWPX-500092-Sample",
+ "DWPX-500087-Sample",
+ "DWPX-500075-Sample",
+ "DWPX-500069-Sample",
+ "DWPX-500101-Sample",
+ "DWPX-500095-Sample",
+ "DWPX-500051-Sample",
+ "DWPX-500100-Sample",
+ "DWPX-500094-Sample",
+ "DWPX-500066-Sample",
+ "DWPX-500077-Sample",
+ "DWPX-500015-Sample",
+ "DWPX-500060-Sample",
+ "DWPX-500054-Sample",
+ "DWPX-500065-Sample",
+ "DWPX-500093-Sample",
+ "DWPW-430181-sample",
+ "DWPX-500102-Sample",
+ "DWPX-500096-Sample",
+ "DWPX-500076-Sample",
+ "DWPW-430139-sample",
+ "DWPX-500018-Sample",
+ "DWPX-500086-Sample",
+ "DWPX-500064-Sample",
+ "DWPX-500058-Sample",
+ "DWPW-430206-sample",
+ "DWPX-500017-Sample",
+ "DWPX-500068-Sample",
+ "DWPX-500073-Sample",
+ "DWPX-500067-Sample",
+ "DWPW-430160-sample",
+ "DWPX-500061-Sample",
+ "DWPW-430048-sample",
+ "DWPW-430159-sample",
+ "DWPW-430115-sample",
+ "DWPX-500016-Sample",
+ "DWQW-61461-sample",
+ "DWQW-61460-sample",
+ "DWQW-61459-sample",
+ "DWQW-61458-sample",
+ "DWQW-61457-sample",
+ "DWQW-61456-sample",
+ "DWQW-61455-sample",
+ "DWQW-61454-sample",
+ "DWQW-61453-sample"
+ ]
+ },
+ "at": "2026-06-20T16:49:30.821Z"
+}
\ No newline at end of file
diff --git a/shopify/activated-viewer/audit.js b/shopify/activated-viewer/audit.js
new file mode 100644
index 00000000..688026cf
--- /dev/null
+++ b/shopify/activated-viewer/audit.js
@@ -0,0 +1,65 @@
+'use strict';
+// Audit recently-ACTIVATED products against the 3 essentials: specs-in-metafields,
+// image (factory), priced (has a real roll/bolt, not sample-only). Read-only GraphQL.
+const fs = require('fs'), path = require('path');
+for (const p of [path.join(__dirname, '..', '.env'), path.join(__dirname, '.env')])
+ if (fs.existsSync(p)) for (const l of fs.readFileSync(p, 'utf8').split('\n')) { const m = l.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, ''); }
+const STORE = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN, VER = '2024-10';
+const MAX_PAGES = Number(process.env.MAX_PAGES || 5); // 5×250 = up to 1250
+
+const Q = `query($cur:String){ products(first:250, query:"status:active", sortKey:CREATED_AT, reverse:true, after:$cur){
+ pageInfo{ hasNextPage endCursor }
+ nodes{ id title handle tags featuredImage{ url } mediaCount{ count }
+ variants(first:15){ nodes{ price sku title } }
+ metafields(first:60){ nodes{ namespace key value } } } } }`;
+
+async function gql(cur) {
+ const r = await fetch(`https://${STORE}/admin/api/${VER}/graphql.json`, {
+ method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query: Q, variables: { cur } })
+ });
+ const j = await r.json();
+ if (j.errors) throw new Error(JSON.stringify(j.errors).slice(0, 200));
+ return j.data.products;
+}
+
+(async () => {
+ let cur = null, all = [];
+ for (let p = 0; p < MAX_PAGES; p++) {
+ const pg = await gql(cur); all.push(...pg.nodes);
+ if (!pg.pageInfo.hasNextPage) break; cur = pg.pageInfo.endCursor;
+ await new Promise(r => setTimeout(r, 350)); // gentle on the API
+ }
+ const fail = { priced: [], image: [], specs: [], width: [] };
+ for (const p of all) {
+ const sku = (p.variants.nodes[0] && p.variants.nodes[0].sku) || p.handle;
+ const priced = p.variants.nodes.some(v => parseFloat(v.price) > 4.25);
+ const hasImage = !!(p.featuredImage && p.featuredImage.url) && (p.mediaCount ? p.mediaCount.count > 0 : true);
+ const mfKeys = p.metafields.nodes.map(m => `${m.namespace}.${m.key}`);
+ const hasSpecs = p.metafields.nodes.some(m => m.namespace === 'specs') ||
+ mfKeys.includes('custom.ai_enriched_at') ||
+ p.metafields.nodes.filter(m => m.namespace === 'custom').length >= 5;
+ const hasWidth = mfKeys.some(k => /\.width$|\.roll_width$|\.product_width$/.test(k)) ||
+ p.metafields.nodes.some(m => m.namespace === 'custom' && /width/i.test(m.key) && m.value);
+ if (!priced) fail.priced.push(sku);
+ if (!hasImage) fail.image.push(sku);
+ if (!hasSpecs) fail.specs.push(sku);
+ if (!hasWidth) fail.width.push(sku);
+ }
+ const n = all.length, pct = x => ((n - x) / n * 100).toFixed(1);
+ const show = a => a.slice(0, 12).join(', ') + (a.length > 12 ? ` …+${a.length - 12}` : '');
+ console.log(`\n=== ACTIVATED-ITEM ESSENTIALS AUDIT — ${n} most-recent active products ===\n`);
+ console.log(`PRICED (roll/bolt > $4.25) : ${n - fail.priced.length}/${n} (${pct(fail.priced.length)}%) — ${fail.priced.length} sample-only/unpriced`);
+ console.log(`HAS IMAGE : ${n - fail.image.length}/${n} (${pct(fail.image.length)}%) — ${fail.image.length} missing`);
+ console.log(`SPECS IN METAFIELDS : ${n - fail.specs.length}/${n} (${pct(fail.specs.length)}%) — ${fail.specs.length} missing specs`);
+ console.log(`WIDTH METAFIELD (hard gate) : ${n - fail.width.length}/${n} (${pct(fail.width.length)}%) — ${fail.width.length} missing width`);
+ console.log('');
+ if (fail.priced.length) console.log(` ✗ not priced : ${show(fail.priced)}`);
+ if (fail.image.length) console.log(` ✗ no image : ${show(fail.image)}`);
+ if (fail.specs.length) console.log(` ✗ no specs : ${show(fail.specs)}`);
+ if (fail.width.length) console.log(` ✗ no width : ${show(fail.width)}`);
+ // persist the fail lists for follow-up
+ fs.writeFileSync(path.join(__dirname, 'audit-result.json'), JSON.stringify({ n, fail, at: new Date().toISOString() }, null, 1));
+ console.log(`\nfull fail-lists → audit-result.json`);
+})().catch(e => { console.error('AUDIT FAIL:', e.message); process.exit(1); });
diff --git a/shopify/activated-viewer/public/index.html b/shopify/activated-viewer/public/index.html
index 2b69053a..4c2ee274 100644
--- a/shopify/activated-viewer/public/index.html
+++ b/shopify/activated-viewer/public/index.html
@@ -5,17 +5,20 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DW — Activated Items by Date</title>
<style>
- :root{ --ink:#1b1b1b; --muted:#6b6b6b; --line:#e6e6e6; --bg:#f5f4f1; --card:#fff; --accent:#1f6f54; --cols:260px; }
+ :root{ --ink:#1b1b1b; --muted:#6b6b6b; --line:#e6e6e6; --bg:#f5f4f1; --card:#fff; --accent:#1f6f54; --bad:#b00020; --cols:260px; }
*{box-sizing:border-box}
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;background:var(--bg);color:var(--ink)}
header{position:sticky;top:0;z-index:10;background:#fff;border-bottom:1px solid var(--line);padding:12px 20px;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
header h1{font-size:18px;margin:0;font-weight:800;letter-spacing:-.3px}
header .meta{font-size:12.5px;color:var(--muted)}
- .controls{margin-left:auto;display:flex;align-items:center;gap:18px;flex-wrap:wrap}
+ .controls{margin-left:auto;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
.controls label{font-size:12px;color:var(--muted);font-weight:700;display:flex;align-items:center;gap:7px}
select,button{font-family:inherit;font-size:13px;border:1px solid var(--line);border-radius:8px;padding:7px 11px;background:#fff;cursor:pointer}
button:hover{background:#f0f0ee}
input[type=range]{accent-color:var(--accent)}
+ .summary{position:sticky;top:57px;z-index:9;display:flex;gap:22px;flex-wrap:wrap;padding:10px 20px;background:#fffdf8;border-bottom:1px solid var(--line);font-size:12.5px;align-items:center}
+ .summary .s b{font-size:15px} .summary .s span{color:var(--muted)}
+ .summary .ok{color:var(--accent)} .summary .warn{color:var(--bad)}
.wrap{padding:18px 20px 60px;max-width:1500px;margin:0 auto}
.daygroup{margin-bottom:26px}
.dayhd{display:flex;align-items:baseline;gap:10px;margin:0 2px 12px;padding-bottom:7px;border-bottom:2px solid var(--line)}
@@ -24,13 +27,19 @@
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--cols),1fr));gap:14px}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;transition:box-shadow .15s}
.card:hover{box-shadow:0 4px 16px rgba(0,0,0,.10)}
+ .card.gap{outline:2px solid #f0b3b3;outline-offset:-1px}
.thumb{aspect-ratio:1/1;background:#eee center/cover no-repeat;position:relative}
- .thumb.none{display:grid;place-items:center;color:#bbb;font-size:12px}
+ .thumb.none{display:grid;place-items:center;color:#bbb;font-size:12px;background:#f6e9e9}
.body{padding:10px 12px 12px;display:flex;flex-direction:column;gap:6px;flex:1}
.title{font-size:13.5px;font-weight:600;line-height:1.3;color:#222;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+ .rowx{display:flex;justify-content:space-between;align-items:center;gap:8px}
.sku{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--accent);font-weight:700;word-break:break-all}
+ .price{font-size:12.5px;font-weight:800;color:#222;white-space:nowrap}
+ .price.sample{color:var(--bad)}
.when{font-size:11.5px;color:var(--muted);display:flex;align-items:center;gap:5px;margin-top:auto}
- .links{display:flex;gap:8px;margin-top:4px}
+ .badges{display:flex;flex-wrap:wrap;gap:4px}
+ .bdg{font-size:9.5px;font-weight:800;letter-spacing:.3px;padding:2px 6px;border-radius:6px;background:#fdeaea;color:var(--bad)}
+ .links{display:flex;gap:8px}
.links a{font-size:11.5px;font-weight:700;color:var(--accent);text-decoration:none}
.links a:hover{text-decoration:underline}
.empty,.loading{padding:60px;text-align:center;color:var(--muted)}
@@ -50,84 +59,88 @@
<option value="sku">SKU A→Z</option>
</select>
</label>
+ <label><input type="checkbox" id="gaps" /> Gaps only</label>
<label>Density <input type="range" id="density" min="160" max="360" step="20" /></label>
<button id="refresh">↻ Refresh</button>
</div>
</header>
+<div class="summary" id="summary"></div>
<div class="wrap"><div id="out" class="loading">Loading activated items from the live store…</div></div>
<script>
let ITEMS = [], STORE = '';
const $ = s => document.querySelector(s);
-const sortSel = $('#sort'), density = $('#density');
+const sortSel = $('#sort'), density = $('#density'), gapsOnly = $('#gaps');
-// persisted controls (standing rule)
sortSel.value = localStorage.getItem('av-sort') || 'newest';
density.value = localStorage.getItem('av-density') || '260';
+gapsOnly.checked = localStorage.getItem('av-gaps') === '1';
applyDensity();
sortSel.onchange = () => { localStorage.setItem('av-sort', sortSel.value); render(); };
density.oninput = () => { localStorage.setItem('av-density', density.value); applyDensity(); };
-function applyDensity(){ document.documentElement.style.setProperty('--cols', density.value + 'px'); }
-
+gapsOnly.onchange = () => { localStorage.setItem('av-gaps', gapsOnly.checked ? '1' : '0'); render(); };
$('#refresh').onclick = () => load(true);
+function applyDensity(){ document.documentElement.style.setProperty('--cols', density.value + 'px'); }
-function fmtDate(iso){
- if(!iso) return '—';
- return new Date(iso).toLocaleString(undefined, { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
-}
-function dayKey(iso){ return iso ? new Date(iso).toLocaleDateString(undefined,{year:'numeric',month:'long',day:'numeric',weekday:'short'}) : 'Unknown date'; }
+const fmtDate = iso => iso ? new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}) : '—';
+const dayKey = iso => iso ? new Date(iso).toLocaleDateString(undefined,{year:'numeric',month:'long',day:'numeric',weekday:'short'}) : 'Unknown date';
+const gapsOf = it => { const g=[]; if(!it.priced)g.push('NO PRICE'); if(!it.hasImage)g.push('NO IMAGE'); if(!it.hasSpecs)g.push('NO SPECS'); if(!it.hasWidth)g.push('NO WIDTH'); return g; };
function card(it){
+ const g = gapsOf(it);
const adminUrl = `https://${STORE}/admin/products/${it.id}`;
const pdp = `https://designerwallcoverings.com/products/${it.handle}`;
- const thumb = it.image
- ? `<div class="thumb" style="background-image:url('${it.image}&width=400')"></div>`
- : `<div class="thumb none">no image</div>`;
- return `<div class="card">
+ const thumb = it.image ? `<div class="thumb" style="background-image:url('${it.image}&width=400')"></div>` : `<div class="thumb none">⚠ no image</div>`;
+ const price = it.priced ? `<span class="price">$${Number(it.price).toFixed(2)}</span>` : `<span class="price sample">sample-only</span>`;
+ return `<div class="card ${g.length?'gap':''}">
${thumb}
<div class="body">
- <div class="sku">${it.sku || it.handle || '—'}</div>
+ <div class="rowx"><span class="sku">${it.sku || it.handle || '—'}</span>${price}</div>
<div class="title">${it.title}</div>
- <div class="when" title="${it.activated || ''}">🕓 ${fmtDate(it.activated)}</div>
+ ${g.length?`<div class="badges">${g.map(b=>`<span class="bdg">${b}</span>`).join('')}</div>`:''}
+ <div class="when" title="${it.activated||''}">🕓 ${fmtDate(it.activated)}</div>
<div class="links"><a href="${pdp}" target="_blank">Store ↗</a><a href="${adminUrl}" target="_blank">Admin ↗</a></div>
</div>
</div>`;
}
+function summaryBar(){
+ const n = ITEMS.length || 1;
+ const pc = f => (ITEMS.filter(f).length/n*100).toFixed(1);
+ const cell = (label,f)=>{ const p=pc(f); const cls=p>=99?'ok':'warn'; return `<div class="s"><b class="${cls}">${p}%</b> <span>${label}</span></div>`; };
+ $('#summary').innerHTML =
+ cell('priced', i=>i.priced) + cell('image', i=>i.hasImage) + cell('specs', i=>i.hasSpecs) + cell('width', i=>i.hasWidth) +
+ `<div class="s" style="margin-left:auto"><b class="warn">${ITEMS.filter(i=>gapsOf(i).length).length}</b> <span>items with gaps</span></div>`;
+}
+
function render(){
+ summaryBar();
const mode = sortSel.value;
let items = ITEMS.slice();
- if(mode==='title') items.sort((a,b)=> (a.title||'').localeCompare(b.title||''));
- else if(mode==='sku') items.sort((a,b)=> (a.sku||a.handle||'').localeCompare(b.sku||b.handle||''));
+ if(gapsOnly.checked) items = items.filter(i=>gapsOf(i).length);
+ if(mode==='title') items.sort((a,b)=>(a.title||'').localeCompare(b.title||''));
+ else if(mode==='sku') items.sort((a,b)=>(a.sku||a.handle||'').localeCompare(b.sku||b.handle||''));
else items.sort((a,b)=> mode==='oldest' ? new Date(a.activated)-new Date(b.activated) : new Date(b.activated)-new Date(a.activated));
const out = $('#out');
- if(!items.length){ out.className='empty'; out.textContent='No activated items found.'; return; }
+ if(!items.length){ out.className='empty'; out.textContent = gapsOnly.checked ? '✓ No gaps in view.' : 'No activated items found.'; return; }
out.className='';
-
if(mode==='newest' || mode==='oldest'){
- // group by activation date
- const groups = [];
- let cur = null;
- for(const it of items){ const k = dayKey(it.activated); if(!cur || cur.k!==k){ cur={k, items:[]}; groups.push(cur);} cur.items.push(it); }
- out.innerHTML = groups.map(g=>`<div class="daygroup">
- <div class="dayhd"><b>${g.k}</b><span class="pill">${g.items.length} activated</span></div>
- <div class="grid">${g.items.map(card).join('')}</div>
- </div>`).join('');
+ const groups=[]; let cur=null;
+ for(const it of items){ const k=dayKey(it.activated); if(!cur||cur.k!==k){cur={k,items:[]};groups.push(cur);} cur.items.push(it); }
+ out.innerHTML = groups.map(g=>`<div class="daygroup"><div class="dayhd"><b>${g.k}</b><span class="pill">${g.items.length}</span></div><div class="grid">${g.items.map(card).join('')}</div></div>`).join('');
} else {
out.innerHTML = `<div class="grid">${items.map(card).join('')}</div>`;
}
}
async function load(refresh){
- $('#out').className='loading'; $('#out').textContent='Loading…';
+ $('#out').className='loading'; $('#out').textContent='Loading from the live store (this pulls metafields, ~a few seconds)…';
const r = await fetch('/api/activated' + (refresh?'?refresh=1':''));
const j = await r.json();
ITEMS = j.items || []; STORE = j.store || '';
const days = new Set(ITEMS.map(i=>dayKey(i.activated))).size;
- $('#meta').innerHTML = j.error
- ? `<span style="color:#b00">⚠ ${j.error}</span>`
- : `${j.count} active items · ${days} days · ${j.store} · fetched ${fmtDate(new Date(j.fetchedAt).toISOString())}`;
+ $('#meta').innerHTML = j.error ? `<span style="color:#b00">⚠ ${j.error}</span>` : `${j.count} active · ${days} days · ${j.store}`;
render();
}
load(false);
diff --git a/shopify/activated-viewer/server.js b/shopify/activated-viewer/server.js
index 4f7cccb3..ccbad577 100644
--- a/shopify/activated-viewer/server.js
+++ b/shopify/activated-viewer/server.js
@@ -21,27 +21,43 @@ const MAX_PAGES = Number(process.env.MAX_PAGES || 4); // 4×250 = up to 1000 mos
let cache = { at: 0, data: null, err: null };
const TTL = 5 * 60 * 1000;
+const GQL = `query($cur:String){ products(first:250, query:"status:active", sortKey:CREATED_AT, reverse:true, after:$cur){
+ pageInfo{ hasNextPage endCursor }
+ nodes{ id title handle publishedAt createdAt featuredImage{ url } mediaCount{ count }
+ variants(first:15){ nodes{ price sku } }
+ metafields(first:60){ nodes{ namespace key value } } } } }`;
+
async function fetchActivated() {
if (!TOKEN) throw new Error('SHOPIFY_ADMIN_TOKEN not set');
const out = [];
- let url = `https://${STORE}/admin/api/${VER}/products.json?status=active&limit=250&order=created_at+desc&fields=id,title,handle,status,created_at,published_at,variants,image`;
- for (let p = 0; p < MAX_PAGES && url; p++) {
- const r = await fetch(url, { headers: { 'X-Shopify-Access-Token': TOKEN } });
+ let cur = null;
+ for (let p = 0; p < MAX_PAGES; p++) {
+ const r = await fetch(`https://${STORE}/admin/api/${VER}/graphql.json`, {
+ method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query: GQL, variables: { cur } })
+ });
if (!r.ok) throw new Error('shopify ' + r.status + ' ' + (await r.text()).slice(0, 120));
const j = await r.json();
- for (const pr of j.products || []) {
- const v = (pr.variants && pr.variants[0]) || {};
+ if (j.errors) throw new Error(JSON.stringify(j.errors).slice(0, 160));
+ const pg = j.data.products;
+ for (const pr of pg.nodes) {
+ const vs = pr.variants.nodes, mf = pr.metafields.nodes;
+ const keys = mf.map(m => `${m.namespace}.${m.key}`);
out.push({
- id: pr.id, title: pr.title, handle: pr.handle,
- sku: v.sku || '', price: v.price || '',
- image: (pr.image && pr.image.src) || '',
- published_at: pr.published_at, created_at: pr.created_at,
- activated: pr.published_at || pr.created_at
+ id: String(pr.id).split('/').pop(), title: pr.title, handle: pr.handle,
+ sku: (vs[0] && vs[0].sku) || '',
+ price: vs.reduce((mx, v) => Math.max(mx, parseFloat(v.price) || 0), 0),
+ image: (pr.featuredImage && pr.featuredImage.url) || '',
+ published_at: pr.publishedAt, created_at: pr.createdAt,
+ activated: pr.publishedAt || pr.createdAt,
+ priced: vs.some(v => parseFloat(v.price) > 4.25),
+ hasImage: !!(pr.featuredImage && pr.featuredImage.url) && (!pr.mediaCount || pr.mediaCount.count > 0),
+ hasSpecs: mf.some(m => m.namespace === 'specs') || keys.includes('custom.ai_enriched_at') || mf.filter(m => m.namespace === 'custom').length >= 5,
+ hasWidth: mf.some(m => /width/i.test(m.key) && m.value && String(m.value).trim() !== '')
});
}
- const link = r.headers.get('link') || '';
- const m = link.match(/<([^>]+)>;\s*rel="next"/);
- url = m ? m[1] : null;
+ if (!pg.pageInfo.hasNextPage) break; cur = pg.pageInfo.endCursor;
+ await new Promise(r => setTimeout(r, 300));
}
out.sort((a, b) => new Date(b.activated) - new Date(a.activated));
return out;
← afcf25ff Add activated-items viewer: live Shopify pull, grouped by ac
·
back to Designer Wallcoverings
·
Activation gate: enforce specs+description+ALL-vendor-images 6396e193 →