← back to Dw Yolo Loop
Vendor-landing template: generalize to any DW line + dw-vendor-landing skill
584bf8a236c0e7557f03fd1115fa72c7946395d8 · 2026-06-12 12:06:02 -0700 · Steve Abrams
- server.js: handlesFile/colorsFile now config-driven (was hardcoded to Artmura)
- build-line.js: throttle + 429/5xx retry + progress log (survives 1900+ product lines like Thibaut); emits handlesFile in config block
- deploy-vendor.sh: generic deploy for CDN-image lines (ships lines/<slug>.json, pm2 <slug>-site, free port 9941-9949, nginx+certbot)
- create-collection.js: idempotent smart-collection creator (reuses existing)
- clickthrough.js: env-driven (BASE/LINE/COLLECTION_SLUG) so it tests any vendor
- site.config.js: Thibaut block added (reuses existing thibaut-wallcoverings collection)
- Skill ~/.claude/skills/dw-vendor-landing codifies the full playbook (Artmura = reference build)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
M artmura-site/build-line.jsM artmura-site/clickthrough.jsA artmura-site/create-collection.jsA artmura-site/deploy-vendor.shM artmura-site/server.jsM artmura-site/site.config.js
Diff
commit 584bf8a236c0e7557f03fd1115fa72c7946395d8
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Jun 12 12:06:02 2026 -0700
Vendor-landing template: generalize to any DW line + dw-vendor-landing skill
- server.js: handlesFile/colorsFile now config-driven (was hardcoded to Artmura)
- build-line.js: throttle + 429/5xx retry + progress log (survives 1900+ product lines like Thibaut); emits handlesFile in config block
- deploy-vendor.sh: generic deploy for CDN-image lines (ships lines/<slug>.json, pm2 <slug>-site, free port 9941-9949, nginx+certbot)
- create-collection.js: idempotent smart-collection creator (reuses existing)
- clickthrough.js: env-driven (BASE/LINE/COLLECTION_SLUG) so it tests any vendor
- site.config.js: Thibaut block added (reuses existing thibaut-wallcoverings collection)
- Skill ~/.claude/skills/dw-vendor-landing codifies the full playbook (Artmura = reference build)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
artmura-site/build-line.js | 19 +++++++++++--
artmura-site/clickthrough.js | 14 +++++----
artmura-site/create-collection.js | 40 ++++++++++++++++++++++++++
artmura-site/deploy-vendor.sh | 60 +++++++++++++++++++++++++++++++++++++++
artmura-site/server.js | 7 +++--
artmura-site/site.config.js | 30 ++++++++++++++++++++
6 files changed, 159 insertions(+), 11 deletions(-)
diff --git a/artmura-site/build-line.js b/artmura-site/build-line.js
index 32af517..808c06d 100644
--- a/artmura-site/build-line.js
+++ b/artmura-site/build-line.js
@@ -23,8 +23,20 @@ const slug = VENDOR.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g
const OUT = path.join(__dirname, 'lines');
fs.mkdirSync(OUT, { recursive: true });
-function api(p) {
- return fetch(`https://${STORE}/admin/api/${API}${p}`, { headers: { 'X-Shopify-Access-Token': TOKEN } });
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+// rate-limited fetch with 429/5xx retry — survives large lines (Thibaut = 1900+ products)
+async function api(p, tries = 5) {
+ for (let i = 0; i < tries; i++) {
+ const res = await fetch(`https://${STORE}/admin/api/${API}${p}`, { headers: { 'X-Shopify-Access-Token': TOKEN } });
+ if (res.status === 429 || res.status >= 500) {
+ const wait = Number(res.headers.get('Retry-After') || 2) * 1000 || 2000;
+ await sleep(wait * (i + 1));
+ continue;
+ }
+ await sleep(110); // ~8/s, under the 2-bucket leak rate
+ return res;
+ }
+ throw new Error(`api ${p} failed after ${tries} tries`);
}
async function getAll() {
let url = `/products.json?vendor=${encodeURIComponent(VENDOR)}&status=active&limit=250`;
@@ -50,7 +62,9 @@ async function metafields(pid) {
const prods = await getAll();
console.log(` ${prods.length} active products`);
const products = [], handles = {};
+ let i = 0;
for (const p of prods) {
+ if (++i % 100 === 0) console.log(` …${i}/${prods.length} products processed`);
const mf = await metafields(p.id);
const isSample = v => (v.option1 || '').toLowerCase() === 'sample' || /-sample$/i.test(v.sku || '');
const sample = p.variants.find(isSample);
@@ -113,6 +127,7 @@ async function metafields(pid) {
booksHeading: '${VENDOR}', metaDescription: 'The ${VENDOR} collection at Designer Wallcoverings.',
dataFile: 'artmura-site/lines/${slug}.json',
colorsFile: 'artmura-site/lines/${slug}-colors.json', // optional: run extract_colors variant
+ handlesFile: 'artmura-site/lines/${slug}-handles.json',
imagePrefix: '', localImages: false, // uses Shopify CDN urls directly
storeBase: 'https://www.designerwallcoverings.com',
palette: { bg: '#f6f2ec', ink: '#211d18', accent: '#8c7a5f', gold: '#a98c54' },
diff --git a/artmura-site/clickthrough.js b/artmura-site/clickthrough.js
index 45a8bf3..1ffa440 100644
--- a/artmura-site/clickthrough.js
+++ b/artmura-site/clickthrough.js
@@ -1,6 +1,8 @@
/* Live click-through of every Artmura landing component. Reports PASS/FAIL per component. */
const { chromium } = require('/Users/stevestudio2/Projects/ventura-corridor/node_modules/playwright');
const BASE = process.env.BASE || 'https://artmura.designerwallcoverings.com';
+const LINE = process.env.LINE || 'Artmura'; // expected hero H1 / line name
+const COLL = process.env.COLLECTION_SLUG || 'artmura'; // expected /collections/<slug>
const results = [];
const ok = (n, cond, extra='') => { results.push({ n, pass: !!cond, extra }); console.log(`${cond?'✓':'✗'} ${n}${extra?' — '+extra:''}`); };
@@ -20,10 +22,10 @@ const ok = (n, cond, extra='') => { results.push({ n, pass: !!cond, extra }); co
const navTxt = await page.$$eval('#cUR a', as => as.map(a=>a.textContent.trim()+' → '+a.getAttribute('href')));
ok('Nav rendered (4 links)', navTxt.length === 4, navTxt.join(' | '));
- ok('Nav "Shop the Collection" → /collections/artmura', navTxt.some(t=>/Shop the Collection/.test(t) && /collections\/artmura/.test(t)));
+ ok(`Nav "Shop the Collection" → /collections/${COLL}`, navTxt.some(t=>/Shop the Collection/.test(t) && new RegExp('collections/'+COLL).test(t)));
ok('Hero eyebrow', (await page.textContent('#heroEyebrow'))?.includes('Designer Wallcoverings'));
- ok('Hero H1 = Artmura', (await page.textContent('#heroH1'))?.trim() === 'Artmura');
+ ok(`Hero H1 = ${LINE}`, (await page.textContent('#heroH1'))?.trim() === LINE);
const slides = await page.$$('.hero .slide'); ok('Hero rotating slides present', slides.length >= 2, slides.length+' slides');
const total = parseInt(await page.textContent('#cnt'), 10);
@@ -78,10 +80,10 @@ const ok = (n, cond, extra='') => { results.push({ n, pass: !!cond, extra }); co
const shareBtns = await page.$$('#shareCollection .sbtn');
ok('Homepage share icons (5)', shareBtns.length === 5, shareBtns.length+' icons');
const pinHref = await page.getAttribute('#shareCollection .sbtn.pin','href');
- ok('Pinterest share → real collection', /pinterest\.com/.test(pinHref) && /collections%2Fartmura/.test(pinHref));
+ ok('Pinterest share → real collection', /pinterest\.com/.test(pinHref) && new RegExp('collections%2F'+COLL).test(pinHref));
// shopbar
- ok('Shopbar → real collection', (await page.getAttribute('#shopbar','href'))?.includes('/collections/artmura'));
+ ok('Shopbar → real collection', (await page.getAttribute('#shopbar','href'))?.includes('/collections/'+COLL));
// ---------- PDP (click a card) ----------
const firstHandle = await page.$eval('.card', c => c.dataset.h);
@@ -136,8 +138,8 @@ const ok = (n, cond, extra='') => { results.push({ n, pass: !!cond, extra }); co
}
// ---------- real collection page resolves ----------
- const collResp = await page.goto('https://www.designerwallcoverings.com/collections/artmura', { waitUntil:'domcontentloaded' });
- ok('Real /collections/artmura is live', collResp.status()===200, 'http '+collResp.status());
+ const collResp = await page.goto('https://www.designerwallcoverings.com/collections/'+COLL, { waitUntil:'domcontentloaded' });
+ ok(`Real /collections/${COLL} is live`, collResp.status()===200, 'http '+collResp.status());
ok('No console/page errors', errs.length===0, errs.slice(0,3).join(' | '));
diff --git a/artmura-site/create-collection.js b/artmura-site/create-collection.js
new file mode 100644
index 0000000..712fd94
--- /dev/null
+++ b/artmura-site/create-collection.js
@@ -0,0 +1,40 @@
+#!/usr/bin/env node
+/**
+ * Create (or find) a published smart collection for a DW vendor line, so
+ * /collections/<handle> resolves on the storefront for the landing's Shop links.
+ *
+ * SHOPIFY_ADMIN_TOKEN=… node create-collection.js "<Vendor>" [handle]
+ *
+ * Idempotent: if a smart/custom collection with the same title OR handle already
+ * exists, it prints that one instead of creating a duplicate. Prints the handle to use.
+ */
+const STORE = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const API = '2024-10';
+const VENDOR = process.argv[2];
+const HANDLE = (process.argv[3] || (VENDOR || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''));
+if (!TOKEN || !VENDOR) { console.error('usage: SHOPIFY_ADMIN_TOKEN=… node create-collection.js "<Vendor>" [handle]'); process.exit(1); }
+
+const api = (p, opts = {}) => fetch(`https://${STORE}/admin/api/${API}${p}`, {
+ ...opts, headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', ...(opts.headers || {}) },
+});
+
+(async () => {
+ // already exists? (by title across smart+custom)
+ for (const kind of ['smart_collections', 'custom_collections']) {
+ const d = await (await api(`/${kind}.json?title=${encodeURIComponent(VENDOR)}`)).json();
+ const hit = (d[kind] || [])[0];
+ if (hit) { console.log(`EXISTS ${kind.slice(0,-1)} id=${hit.id} handle=${hit.handle} published=${!!hit.published_at}`); console.log(`USE_HANDLE=${hit.handle}`); return; }
+ }
+ const body = { smart_collection: {
+ title: VENDOR, handle: HANDLE,
+ body_html: `<p>The <strong>${VENDOR}</strong> collection at Designer Wallcoverings — to the trade.</p>`,
+ sort_order: 'created-desc', published: true, disjunctive: false,
+ rules: [{ column: 'vendor', relation: 'equals', condition: VENDOR }],
+ }};
+ const res = await api('/smart_collections.json', { method: 'POST', body: JSON.stringify(body) });
+ const d = await res.json();
+ if (!d.smart_collection) { console.error('ERR', res.status, JSON.stringify(d)); process.exit(1); }
+ console.log(`CREATED smart_collection id=${d.smart_collection.id} handle=${d.smart_collection.handle} published=${!!d.smart_collection.published_at}`);
+ console.log(`USE_HANDLE=${d.smart_collection.handle}`);
+})().catch(e => { console.error(e); process.exit(1); });
diff --git a/artmura-site/deploy-vendor.sh b/artmura-site/deploy-vendor.sh
new file mode 100644
index 0000000..13a331b
--- /dev/null
+++ b/artmura-site/deploy-vendor.sh
@@ -0,0 +1,60 @@
+#!/usr/bin/env bash
+# Generic DW vendor-landing deploy — any line built with build-line.js (CDN images).
+# VENDOR=thibaut DOMAIN=thibaut.designerwallcoverings.com bash deploy-vendor.sh
+# Self-contained: ships the template app + the line's data snapshot; pm2 (BUNDLE=1) + nginx + Let's Encrypt.
+# For Artmura specifically (local downloaded images) use deploy-landing.sh instead.
+set -euo pipefail
+KAM="${KAM:-root@45.61.58.125}"
+VENDOR="${VENDOR:?set VENDOR=<slug>}"
+DOMAIN="${DOMAIN:?set DOMAIN=<sub.designerwallcoverings.com>}"
+APP="/root/Projects/${VENDOR}-site"
+PM2="${VENDOR}-site"
+SITE="$(cd "$(dirname "$0")" && pwd)" # the canonical template app dir (artmura-site)
+DATA="$SITE/lines/${VENDOR}.json"
+HANDLES="$SITE/lines/${VENDOR}-handles.json"
+COLORS="$SITE/lines/${VENDOR}-colors.json" # optional
+
+[ -f "$DATA" ] || { echo "missing $DATA — run: node build-line.js \"<Vendor>\" first"; exit 1; }
+[ -f "$HANDLES" ] || { echo "missing $HANDLES"; exit 1; }
+
+echo "→ resolving port (reuse if $PM2 already deployed, else a free one)"
+PORT=$(ssh "$KAM" "
+ cur=\$(pm2 jlist 2>/dev/null | node -e \"try{const a=JSON.parse(require('fs').readFileSync(0));const p=a.find(x=>x.name==='$PM2');process.stdout.write(String((p&&p.pm2_env&&p.pm2_env.env&&p.pm2_env.env.PORT)||''))}catch(e){}\" 2>/dev/null)
+ if [ -n \"\$cur\" ]; then echo \"\$cur\"; else
+ for p in 9941 9942 9943 9944 9945 9946 9947 9948 9949; do ss -ltn | grep -q \":\$p \" || { echo \$p; break; }; done
+ fi")
+[ -z "$PORT" ] && { echo "no free port found"; exit 1; }
+echo " using port $PORT"
+
+echo "→ shipping app + line data to $KAM:$APP"
+ssh "$KAM" "mkdir -p $APP/_data"
+rsync -az --delete --exclude node_modules --exclude _images --exclude _data --exclude lines "$SITE/" "$KAM:$APP/"
+rsync -az "$DATA" "$HANDLES" "$KAM:$APP/_data/"
+[ -f "$COLORS" ] && rsync -az "$COLORS" "$KAM:$APP/_data/" || true
+
+echo "→ install + pm2 (BUNDLE=1, VENDOR=$VENDOR, port $PORT)"
+ssh "$KAM" "cd $APP && npm install --omit=dev --silent; pm2 delete $PM2 2>/dev/null; cd $APP && VENDOR=$VENDOR BUNDLE=1 PORT=$PORT pm2 start server.js --name $PM2 && pm2 save"
+
+echo "→ nginx vhost"
+ssh "$KAM" "cat > /etc/nginx/sites-available/$DOMAIN <<NGINX
+server {
+ listen 80;
+ server_name $DOMAIN;
+ location / {
+ proxy_pass http://127.0.0.1:$PORT;
+ proxy_set_header Host \\\$host;
+ proxy_set_header X-Real-IP \\\$remote_addr;
+ proxy_set_header X-Forwarded-For \\\$proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto \\\$scheme;
+ }
+}
+NGINX
+ln -sf /etc/nginx/sites-available/$DOMAIN /etc/nginx/sites-enabled/$DOMAIN
+nginx -t && systemctl reload nginx"
+
+echo "→ Let's Encrypt"
+ssh "$KAM" "certbot --nginx -d $DOMAIN --non-interactive --agree-tos -m steve@designerwallcoverings.com --redirect || echo 'certbot: ensure DNS A record exists first, then re-run certbot'"
+
+echo "→ smoke test"
+ssh "$KAM" "curl -s -o /dev/null -w 'local pm2 → %{http_code}\n' http://127.0.0.1:$PORT/api/config"
+echo "DONE. Ensure Cloudflare DNS: A $DOMAIN → 45.61.58.125 (proxied)."
diff --git a/artmura-site/server.js b/artmura-site/server.js
index 6ee569a..0090b23 100644
--- a/artmura-site/server.js
+++ b/artmura-site/server.js
@@ -13,10 +13,11 @@ if (!CFG) { console.error(`No site.config for VENDOR=${VENDOR}`); process.exit(1
// BUNDLE=1 → self-contained deploy: read data/images from alongside the app (./_data, ./_images),
// so the host needs only this one directory (no repo-relative ../ paths).
const BUNDLE = process.env.BUNDLE === '1';
+const HANDLES_FILE = CFG.handlesFile || 'scripts/artmura-onboard/data/artmura-store-handles.json';
const PKG = BUNDLE ? path.join(__dirname, '_data', path.basename(CFG.dataFile)) : path.join(ROOT, CFG.dataFile);
-const COLORS_PATH = BUNDLE ? path.join(__dirname, '_data', path.basename(CFG.colorsFile)) : path.join(ROOT, CFG.colorsFile);
-const HANDLES_PATH = BUNDLE ? path.join(__dirname, '_data', 'artmura-store-handles.json') : path.join(ROOT, 'scripts/artmura-onboard/data/artmura-store-handles.json');
-const IMG_DIR = BUNDLE ? path.join(__dirname, '_images') : path.join(ROOT, CFG.imagePrefix);
+const COLORS_PATH = BUNDLE ? path.join(__dirname, '_data', path.basename(CFG.colorsFile || 'none.json')) : path.join(ROOT, CFG.colorsFile || 'none.json');
+const HANDLES_PATH = BUNDLE ? path.join(__dirname, '_data', path.basename(HANDLES_FILE)) : path.join(ROOT, HANDLES_FILE);
+const IMG_DIR = BUNDLE ? path.join(__dirname, '_images') : path.join(ROOT, CFG.imagePrefix || '.');
const PORT = process.env.PORT || 9921;
const app = express();
diff --git a/artmura-site/site.config.js b/artmura-site/site.config.js
index 885af0e..4439c45 100644
--- a/artmura-site/site.config.js
+++ b/artmura-site/site.config.js
@@ -29,10 +29,40 @@ module.exports = {
metaDescription: 'The Artmura collection at Designer Wallcoverings — architectural, hand-finished Italian non-woven wallcoverings. 161 designs across Opera & La Scala Milano, sold by the yard, to the trade.',
dataFile: 'scripts/artmura-onboard/data/artmura.json',
colorsFile: 'scripts/artmura-onboard/data/artmura-colors.json',
+ handlesFile: 'scripts/artmura-onboard/data/artmura-store-handles.json',
imagePrefix: '.newwall-ref/artmura/images',
localImages: true, // Artmura images were downloaded locally; generic lines use CDN urls
// live store: cards/PDP CTAs link OUT to the real Shopify product for purchase
storeBase: 'https://www.designerwallcoverings.com',
palette: { bg: '#f6f2ec', ink: '#211d18', accent: '#8c7a5f', gold: '#a98c54' },
},
+
+ thibaut: {
+ // HOUSE brand — always Designer Wallcoverings
+ house: 'Designer Wallcoverings',
+ houseUrl: 'https://www.designerwallcoverings.com',
+ houseTagline: 'Designer Wallcoverings & Fabrics · To the Trade',
+ nav: [
+ { label: 'All Wallcoverings', href: 'https://www.designerwallcoverings.com/collections/all' },
+ { label: 'The Collection', href: '#catalog' },
+ { label: 'Trade', href: 'https://www.designerwallcoverings.com/pages/trade-program' },
+ { label: 'Shop the Collection ↗', href: 'https://www.designerwallcoverings.com/collections/thibaut-wallcoverings' },
+ ],
+ collectionUrl: 'https://www.designerwallcoverings.com/collections/thibaut-wallcoverings',
+ // LINE — Thibaut, a collection DW carries (NOT the property brand)
+ vendor: 'Thibaut', line: 'Thibaut',
+ title: 'Thibaut Wallcoverings | Designer Wallcoverings',
+ wordmark: 'Designer Wallcoverings',
+ eyebrow: 'A Designer Wallcoverings Collection',
+ kicker: 'Est. 1886 · American Heritage Wallcoverings',
+ tagline: 'Timeless prints, wovens & grasscloths — curated by Designer Wallcoverings.',
+ booksHeading: 'The Thibaut Collections',
+ metaDescription: 'The Thibaut collection at Designer Wallcoverings — heritage American wallcoverings: botanical prints, textures, grasscloths and wovens, to the trade.',
+ dataFile: 'artmura-site/lines/thibaut.json',
+ colorsFile: 'artmura-site/lines/thibaut-colors.json',
+ handlesFile: 'artmura-site/lines/thibaut-handles.json',
+ imagePrefix: '', localImages: false, // Shopify CDN urls
+ storeBase: 'https://www.designerwallcoverings.com',
+ palette: { bg: '#f6f2ec', ink: '#211d18', accent: '#8c7a5f', gold: '#a98c54' },
+ },
};
← 4ec71ec Artmura landing: add Playwright component click-through (39/
·
back to Dw Yolo Loop
·
Thibaut snapshot: 1903 products built + cleaned (-SAMPLE suf 2f4b8ae →