← back to Dw Vendor Microsites
initial scaffold — generalized cowtan engine + lib + harness + gated deploy/dns scripts
7ca97322eff0dd5372421850bcb59dacec9df823 · 2026-06-30 12:44:50 -0700 · Steve
Files touched
A .gitignoreA build-vendor.shA bulk-dns.shA deploy-vendor.shA issue-wildcard.shA launch-wave.shA launch-window.shA lib/export-catalog.shA lib/image-health.shA lib/manifest-lib.shA lib/port-alloc.shA lib/resolve-vendor.shA lib/smoke-local.shA manifest.jsonA seed-manifest.shA template/public/index.htmlA template/server.js
Diff
commit 7ca97322eff0dd5372421850bcb59dacec9df823
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Jun 30 12:44:50 2026 -0700
initial scaffold — generalized cowtan engine + lib + harness + gated deploy/dns scripts
---
.gitignore | 16 +++
build-vendor.sh | 133 ++++++++++++++++++
bulk-dns.sh | 61 ++++++++
deploy-vendor.sh | 94 +++++++++++++
issue-wildcard.sh | 31 ++++
launch-wave.sh | 35 +++++
launch-window.sh | 79 +++++++++++
lib/export-catalog.sh | 102 ++++++++++++++
lib/image-health.sh | 76 ++++++++++
lib/manifest-lib.sh | 59 ++++++++
lib/port-alloc.sh | 63 +++++++++
lib/resolve-vendor.sh | 101 +++++++++++++
lib/smoke-local.sh | 71 ++++++++++
manifest.json | 1 +
seed-manifest.sh | 149 ++++++++++++++++++++
template/public/index.html | 342 +++++++++++++++++++++++++++++++++++++++++++++
template/server.js | 248 ++++++++++++++++++++++++++++++++
17 files changed, 1661 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0d9ca1d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,16 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+# lock files for the flock-based allocators
+.port-alloc.lock
+.manifest.lock
+# per-vendor sample requests are runtime data, not source
+data/sample-requests.jsonl
+vendors/*/viewer/data/sample-requests.jsonl
+# NOTE: staging/*.jsonl is intentionally KEPT in git (the exported, scrubbed catalog
+# snapshots are the resumable build inputs — OK to keep, per plan).
diff --git a/build-vendor.sh b/build-vendor.sh
new file mode 100755
index 0000000..96ca943
--- /dev/null
+++ b/build-vendor.sh
@@ -0,0 +1,133 @@
+#!/usr/bin/env bash
+# build-vendor.sh <vendor_code>
+#
+# Idempotent / resumable per-vendor build harness. Takes a vendor to local-verified
+# BUILT status (NOT deployed). One iTerm Claude session runs this end-to-end.
+#
+# Steps (per the approved plan §2):
+# 0. private-label / exclusion gate (lib/resolve-vendor.sh)
+# 1. export <vendor>_catalog -> staging/<vendor>.jsonl (lib/export-catalog.sh, scrubbed)
+# 2. image health (HEAD-sample + relink) (lib/image-health.sh)
+# 3. scaffold template -> vendors/<vendor>/viewer + write fieldmap.json
+# 4. allocate free port >= 10000 (lib/port-alloc.sh)
+# 5. local smoke (200 + count + facets) (lib/smoke-local.sh)
+# 6. update manifest.json + git commit
+#
+# LOCAL ONLY: no deploy, no DNS, no Shopify, no dw_unified writes, no spend.
+set -euo pipefail
+
+VENDOR="${1:?usage: build-vendor.sh <vendor_code>}"
+REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$REPO"
+source "$REPO/lib/manifest-lib.sh"
+
+log() { printf '\033[1;36m[build:%s]\033[0m %s\n' "$VENDOR" "$*"; }
+die() { printf '\033[1;31m[build:%s] FAIL\033[0m %s\n' "$VENDOR" "$*" >&2; exit 1; }
+
+# ---------- step 0: resolve / gate ----------
+log "step 0 — private-label / exclusion gate"
+RES="$(bash "$REPO/lib/resolve-vendor.sh" "$VENDOR")" || {
+ st=$?
+ if [ "$st" = "3" ]; then
+ log "EXCLUDED — $RES"
+ manifest_upsert "$VENDOR" "{\"build_status\":\"EXCLUDED\",\"status\":\"EXCLUDED\"}"
+ exit 0
+ fi
+ die "resolve failed ($st): $RES"
+}
+HOUSE="$(echo "$RES" | python3 -c 'import sys,json;print(json.load(sys.stdin)["house_name"])')"
+SUB="$(echo "$RES" | python3 -c 'import sys,json;print(json.load(sys.stdin)["subdomain"])')"
+CTABLE="$(echo "$RES" | python3 -c 'import sys,json;print(json.load(sys.stdin)["catalog_table"])')"
+IS_PL="$(echo "$RES" | python3 -c 'import sys,json;print(str(json.load(sys.stdin).get("is_private_label",False)).lower())')"
+SAMPLES_ONLY="$(echo "$RES" | python3 -c 'import sys,json;print(str(json.load(sys.stdin).get("samples_only",True)).lower())')"
+BANNED_JSON="$(echo "$RES" | python3 -c 'import sys,json;print(json.dumps(json.load(sys.stdin).get("banned_names",[])))')"
+mapfile -t BANNED < <(echo "$BANNED_JSON" | python3 -c 'import sys,json;[print(x) for x in json.load(sys.stdin)]')
+log "house=$HOUSE subdomain=$SUB table=$CTABLE private_label=$IS_PL samples_only=$SAMPLES_ONLY banned=[${BANNED[*]+${BANNED[*]}}]"
+
+# ---------- step 1: export + scrub ----------
+JSONL="$REPO/staging/${VENDOR}.jsonl"
+log "step 1 — export ${CTABLE} -> staging/${VENDOR}.jsonl (scrubbing ${#BANNED[@]} banned names)"
+TABLE_COUNT="$(ssh my-server "sudo -u postgres psql -d dw_unified -tAc \"select count(*) from ${CTABLE};\"" 2>/dev/null | tr -d ' ')"
+[ -z "$TABLE_COUNT" ] && die "could not count ${CTABLE}"
+EXPORTED="$(bash "$REPO/lib/export-catalog.sh" "$CTABLE" "$JSONL" "${BANNED[@]+"${BANNED[@]}"}")" || die "export failed"
+log "exported ${EXPORTED} rows (table count ${TABLE_COUNT})"
+
+# leak verification for private-label vendors: assert no banned name survives
+LEAK="ok"
+if [ "${#BANNED[@]}" -gt 0 ]; then
+ for b in "${BANNED[@]}"; do
+ if grep -qiF "$b" "$JSONL"; then
+ LEAK="LEAK:$b"; break
+ fi
+ done
+ if [ "$LEAK" != "ok" ]; then
+ die "private-label LEAK detected in JSONL: $LEAK"
+ fi
+ log "leak check — ZERO banned upstream names in JSONL ✓"
+fi
+
+# ---------- step 2: image health ----------
+log "step 2 — image health"
+HEALTH_JSON="$(bash "$REPO/lib/image-health.sh" "$JSONL" 40)" || HEALTH_JSON='{"image_health":0,"status":"UNKNOWN"}'
+HEALTH="$(echo "$HEALTH_JSON" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("image_health",0))')"
+HEALTH_STATUS="$(echo "$HEALTH_JSON" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("status","UNKNOWN"))')"
+log "image_health=$HEALTH status=$HEALTH_STATUS — $HEALTH_JSON"
+# NEEDS_RESCRAPE is a flag, not a blocker (per plan)
+[ "$HEALTH_STATUS" = "NEEDS_RESCRAPE" ] || [ "$HEALTH_STATUS" = "EMPTY" ] && log "FLAGGED NEEDS_RESCRAPE (not blocking build)"
+
+# ---------- step 3: scaffold ----------
+VDIR="$REPO/vendors/${VENDOR}/viewer"
+log "step 3 — scaffold template -> vendors/${VENDOR}/viewer + fieldmap.json"
+mkdir -p "$VDIR" "$REPO/vendors/${VENDOR}"
+cp "$REPO/template/server.js" "$VDIR/server.js"
+rm -rf "$VDIR/public"; cp -R "$REPO/template/public" "$VDIR/public"
+cat > "$VDIR/fieldmap.json" <<JSON
+{
+ "title": "$(echo "$HOUSE" | sed 's/"/\\"/g')",
+ "samples_only": ${SAMPLES_ONLY},
+ "vendor_code": "${VENDOR}",
+ "subdomain": "${SUB}"
+}
+JSON
+
+# ---------- step 4: port ----------
+log "step 4 — allocate port"
+PORT="$(bash "$REPO/lib/port-alloc.sh" "$VENDOR")" || die "port-alloc failed"
+log "port=$PORT"
+
+# ---------- step 5: local smoke ----------
+log "step 5 — local smoke"
+SMOKE_JSON="$(bash "$REPO/lib/smoke-local.sh" "$VDIR" "$JSONL" "$VDIR/fieldmap.json" "$EXPORTED")" || {
+ echo "$SMOKE_JSON"
+ manifest_upsert "$VENDOR" "{\"build_status\":\"SMOKE_FAIL\",\"port\":$PORT,\"sku_count\":$EXPORTED}"
+ die "smoke FAILED: $SMOKE_JSON"
+}
+log "smoke PASS — $SMOKE_JSON"
+
+# ---------- step 6: manifest + git ----------
+log "step 6 — manifest + git"
+BANNED_FOR_JSON="$BANNED_JSON"
+manifest_upsert "$VENDOR" "$(python3 -c "
+import json
+print(json.dumps({
+ 'house_name': '$HOUSE',
+ 'subdomain': '$SUB',
+ 'catalog_table': '$CTABLE',
+ 'is_private_label': '$IS_PL'=='true',
+ 'banned_names': json.loads('''$BANNED_FOR_JSON'''),
+ 'port': $PORT,
+ 'sku_count': $EXPORTED,
+ 'table_count': $TABLE_COUNT,
+ 'image_health': $HEALTH,
+ 'image_status': '$HEALTH_STATUS',
+ 'samples_only': '$SAMPLES_ONLY'=='true',
+ 'build_status': 'BUILT' if '$HEALTH_STATUS' not in ('NEEDS_RESCRAPE','EMPTY') else 'BUILT_NEEDS_RESCRAPE',
+ 'deploy_status': 'PENDING',
+}))
+")"
+
+git -C "$REPO" add -A
+git -C "$REPO" commit -q -m "build ${VENDOR} (${HOUSE}) -> BUILT, port ${PORT}, ${EXPORTED} skus, img ${HEALTH}" || true
+
+log "DONE — ${VENDOR} -> ${HOUSE} @ ${SUB}.designerwallcoverings.com port ${PORT} ${EXPORTED} skus img ${HEALTH} status BUILT"
+echo "$RES" | python3 -c 'import sys,json;d=json.load(sys.stdin);print()' 2>/dev/null || true
diff --git a/bulk-dns.sh b/bulk-dns.sh
new file mode 100755
index 0000000..a6f8520
--- /dev/null
+++ b/bulk-dns.sh
@@ -0,0 +1,61 @@
+#!/usr/bin/env bash
+# bulk-dns.sh [vendor_code ...] — ONE-TIME throttled A-records -> 45.61.58.125
+#
+# GATED: DNS change (customer-facing infra). Do NOT run without Steve's approval.
+#
+# Creates a Cloudflare A record <subdomain>.designerwallcoverings.com -> 45.61.58.125
+# for each subdomain in the manifest (or only the vendor_codes passed as args).
+# Throttled 1 request/sec to respect Cloudflare's API rate limit. DNS-only (proxied=false)
+# so the nginx wildcard cert + listen-IP vhost serve directly (matches cowtan).
+#
+# Token: CLOUDFLARE_API_TOKEN from ~/Projects/secrets-manager/.env
+# Zone: e4b88c70e4c949f4556cc693133c2f42 (designerwallcoverings.com)
+set -euo pipefail
+
+REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+MANIFEST="$REPO/manifest.json"
+ZONE="e4b88c70e4c949f4556cc693133c2f42"
+IP="45.61.58.125"
+ENVF="$HOME/Projects/secrets-manager/.env"
+
+TOKEN="$(grep -E '^CLOUDFLARE_API_TOKEN=' "$ENVF" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"'"'"' ' || true)"
+[ -z "$TOKEN" ] && { echo "bulk-dns: CLOUDFLARE_API_TOKEN not found in $ENVF" >&2; exit 1; }
+
+# build the subdomain list
+if [ "$#" -gt 0 ]; then
+ SUBS="$(python3 -c "
+import json,sys
+m=json.load(open('$MANIFEST'))
+want=set(sys.argv[1:])
+for code,v in (m.get('vendors') or {}).items():
+ if code in want and v.get('subdomain') and v.get('build_status','').startswith('BUILT'):
+ print(v['subdomain'])
+" "$@")"
+else
+ SUBS="$(python3 -c "
+import json
+m=json.load(open('$MANIFEST'))
+for code,v in (m.get('vendors') or {}).items():
+ if v.get('subdomain') and v.get('build_status','').startswith('BUILT'):
+ print(v['subdomain'])
+")"
+fi
+
+[ -z "$SUBS" ] && { echo "bulk-dns: no matching BUILT subdomains in manifest" >&2; exit 1; }
+
+echo "== creating A records -> $IP (throttled 1/sec) =="
+while IFS= read -r sub; do
+ [ -z "$sub" ] && continue
+ name="${sub}.designerwallcoverings.com"
+ echo -n " ${name} ... "
+ resp="$(curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE}/dns_records" \
+ -H "Authorization: Bearer ${TOKEN}" \
+ -H "Content-Type: application/json" \
+ --data "{\"type\":\"A\",\"name\":\"${name}\",\"content\":\"${IP}\",\"ttl\":300,\"proxied\":false}")"
+ ok="$(echo "$resp" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("success"))' 2>/dev/null || echo False)"
+ if [ "$ok" = "True" ]; then echo "OK"; else
+ echo "FAIL: $(echo "$resp" | python3 -c 'import sys,json;d=json.load(sys.stdin);print(d.get("errors"))' 2>/dev/null || echo "$resp")"
+ fi
+ sleep 1
+done <<< "$SUBS"
+echo "== DONE =="
diff --git a/deploy-vendor.sh b/deploy-vendor.sh
new file mode 100755
index 0000000..599d47a
--- /dev/null
+++ b/deploy-vendor.sh
@@ -0,0 +1,94 @@
+#!/usr/bin/env bash
+# deploy-vendor.sh <vendor_code>
+#
+# Wildcard-cert deploy of one BUILT vendor microsite to Kamatera (45.61.58.125).
+# Wildcard version of ~/deploy-cowtan.sh: NO per-host certbot — every vhost references
+# the single /etc/letsencrypt/live/designerwallcoverings.com/ wildcard cert.
+#
+# GATED: this is an outward-facing prod write. Do NOT run without Steve's approval.
+#
+# Reads the vendor's port + subdomain from manifest.json. Single-pass nginx vhost,
+# Basic Auth admin/DW2024!, X-Robots-Tag noindex, listen 45.61.58.125:443 ssl http2.
+set -euo pipefail
+
+VENDOR="${1:?usage: deploy-vendor.sh <vendor_code>}"
+REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "$REPO/lib/manifest-lib.sh"
+
+PORT="$(manifest_get "$VENDOR" port)"
+SUB="$(manifest_get "$VENDOR" subdomain)"
+HOUSE="$(manifest_get "$VENDOR" house_name)"
+BUILD_STATUS="$(manifest_get "$VENDOR" build_status)"
+[ -z "$PORT" ] && { echo "deploy: no port for $VENDOR in manifest — build it first" >&2; exit 1; }
+[ -z "$SUB" ] && { echo "deploy: no subdomain for $VENDOR in manifest" >&2; exit 1; }
+case "$BUILD_STATUS" in BUILT*) : ;; *) echo "deploy: $VENDOR not BUILT (status=$BUILD_STATUS)" >&2; exit 1;; esac
+
+DOMAIN="${SUB}.designerwallcoverings.com"
+PM2NAME="${VENDOR}-viewer"
+JSONL_REMOTE="/root/Projects/dw-vendor-microsites/staging/${VENDOR}.jsonl"
+VDIR_REMOTE="/root/Projects/dw-vendor-microsites/vendors/${VENDOR}/viewer"
+
+echo "== deploy ${VENDOR} -> https://${DOMAIN} (port ${PORT}, ${HOUSE}) =="
+
+echo "== 1. rsync viewer + staging -> Kamatera =="
+ssh my-server "mkdir -p ${VDIR_REMOTE} /root/Projects/dw-vendor-microsites/staging"
+rsync -az --exclude node_modules --exclude .git --exclude '*.log' \
+ --exclude 'data/sample-requests.jsonl' \
+ "$REPO/vendors/${VENDOR}/viewer/" "my-server:${VDIR_REMOTE}/"
+rsync -az "$REPO/staging/${VENDOR}.jsonl" "my-server:${JSONL_REMOTE}"
+
+echo "== 2. pm2 + nginx vhost (wildcard cert, no per-host certbot) =="
+ssh my-server "bash -s" <<REMOTE
+set -e
+cd ${VDIR_REMOTE}
+pm2 delete ${PM2NAME} 2>/dev/null || true
+VENDOR_JSONL=${JSONL_REMOTE} VENDOR_FIELDMAP=${VDIR_REMOTE}/fieldmap.json \
+ pm2 start server.js --name ${PM2NAME} -- ${PORT} ${JSONL_REMOTE} ${VDIR_REMOTE}/fieldmap.json
+pm2 save
+sleep 2
+echo " app health: \$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:${PORT}/)"
+
+# shared htpasswd (admin / DW2024!) reused across the fleet
+[ -f /etc/nginx/.htpasswd-dwvendors ] || htpasswd -bc /etc/nginx/.htpasswd-dwvendors admin 'DW2024!'
+
+cat > /etc/nginx/sites-available/${DOMAIN}.conf <<VH
+server {
+ server_name ${DOMAIN};
+ auth_basic "Designer Wallcoverings — Trade Lines (Restricted)";
+ auth_basic_user_file /etc/nginx/.htpasswd-dwvendors;
+ add_header X-Frame-Options SAMEORIGIN always;
+ add_header X-Content-Type-Options nosniff always;
+ add_header X-Robots-Tag "noindex, nofollow" always;
+ location / {
+ proxy_pass http://127.0.0.1:${PORT};
+ proxy_http_version 1.1;
+ 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;
+ proxy_read_timeout 60s;
+ }
+ listen 45.61.58.125:443 ssl http2;
+ ssl_certificate /etc/letsencrypt/live/designerwallcoverings.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/designerwallcoverings.com/privkey.pem;
+ include /etc/letsencrypt/options-ssl-nginx.conf;
+ ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+}
+server {
+ if (\\\$host = ${DOMAIN}) { return 301 https://\\\$host\\\$request_uri; }
+ listen 80;
+ server_name ${DOMAIN};
+ return 404;
+}
+VH
+ln -sf /etc/nginx/sites-available/${DOMAIN}.conf /etc/nginx/sites-enabled/${DOMAIN}
+nginx -t && systemctl reload nginx
+
+echo "== smoke (via listen IP) =="
+echo " no-auth (want 401): \$(curl -s -o /dev/null -w '%{http_code}' -k --resolve ${DOMAIN}:443:45.61.58.125 https://${DOMAIN}/)"
+echo " admin (want 200): \$(curl -s -o /dev/null -w '%{http_code}' -k -u admin:DW2024! --resolve ${DOMAIN}:443:45.61.58.125 https://${DOMAIN}/)"
+REMOTE
+
+# record deploy_status (local manifest only; the prod write itself is the gated part)
+manifest_upsert "$VENDOR" "{\"deploy_status\":\"DEPLOYED\"}"
+echo "== DONE — https://${DOMAIN} (admin / DW2024!) =="
diff --git a/issue-wildcard.sh b/issue-wildcard.sh
new file mode 100755
index 0000000..fbd6653
--- /dev/null
+++ b/issue-wildcard.sh
@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+# issue-wildcard.sh — ONE-TIME wildcard cert for *.designerwallcoverings.com (DNS-01)
+#
+# GATED: irreversible-ish (consumes a Let's Encrypt issuance; account already at ~27/50
+# this week). Do NOT run without Steve's approval. Run once; every vendor vhost then
+# references /etc/letsencrypt/live/designerwallcoverings.com/.
+#
+# certbot-dns-cloudflare is already installed; creds at /root/.secrets/cloudflare.ini.
+# Proven on bubbe.ai. Per-subdomain certbot would blow the 50/week cap.
+set -euo pipefail
+
+echo "== issuing ONE wildcard cert: *.designerwallcoverings.com + apex (DNS-01 via Cloudflare) =="
+ssh my-server "bash -s" <<'REMOTE'
+set -e
+test -f /root/.secrets/cloudflare.ini || { echo "missing /root/.secrets/cloudflare.ini"; exit 1; }
+chmod 600 /root/.secrets/cloudflare.ini || true
+
+certbot certonly \
+ --dns-cloudflare \
+ --dns-cloudflare-credentials /root/.secrets/cloudflare.ini \
+ --dns-cloudflare-propagation-seconds 30 \
+ -d '*.designerwallcoverings.com' \
+ -d 'designerwallcoverings.com' \
+ --non-interactive --agree-tos -m steve@designerwallcoverings.com \
+ --cert-name designerwallcoverings.com
+
+echo "== cert installed =="
+ls -l /etc/letsencrypt/live/designerwallcoverings.com/ || true
+certbot certificates 2>/dev/null | grep -A4 designerwallcoverings.com || true
+REMOTE
+echo "== DONE — wildcard cert ready; vendor vhosts can reference it now =="
diff --git a/launch-wave.sh b/launch-wave.sh
new file mode 100755
index 0000000..f7ea29c
--- /dev/null
+++ b/launch-wave.sh
@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+# launch-wave.sh <wave_number>
+#
+# Chunks a wave's PENDING vendors (from manifest.json, wave==N, build_status not yet
+# BUILT/EXCLUDED) into windows of 6 and opens each via launch-window.sh.
+# Recommend 2-3 windows/wave after the pilot; opens them all with a gap between.
+#
+# Mac2-only. Max-plan claude (= $0).
+set -euo pipefail
+
+REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+WAVE="${1:?usage: launch-wave.sh <wave_number>}"
+MANIFEST="$REPO/manifest.json"
+
+mapfile -t PENDING < <(python3 -c "
+import json
+m=json.load(open('$MANIFEST'))
+for code,v in (m.get('vendors') or {}).items():
+ if str(v.get('wave'))=='$WAVE' and (v.get('build_status') or 'PENDING') not in ('BUILT','BUILT_NEEDS_RESCRAPE','EXCLUDED'):
+ print(code)
+")
+
+N=${#PENDING[@]}
+[ "$N" -eq 0 ] && { echo "launch-wave: no PENDING vendors in wave $WAVE" >&2; exit 0; }
+echo "wave $WAVE: $N pending vendors -> $(( (N+5)/6 )) window(s) of <=6"
+
+i=0
+while [ "$i" -lt "$N" ]; do
+ chunk=("${PENDING[@]:$i:6}")
+ echo " window: ${chunk[*]}"
+ bash "$REPO/launch-window.sh" "${chunk[@]}"
+ i=$(( i + 6 ))
+ [ "$i" -lt "$N" ] && sleep 3
+done
+echo "launch-wave $WAVE: launched all windows."
diff --git a/launch-window.sh b/launch-window.sh
new file mode 100755
index 0000000..c3c20f0
--- /dev/null
+++ b/launch-window.sh
@@ -0,0 +1,79 @@
+#!/usr/bin/env bash
+# launch-window.sh <vendor_code> [vendor_code ...] (max 6)
+#
+# Opens ONE iTerm2 window with a 6-pane grid; each pane cd's to this repo and runs a
+# Claude session instructed to build that vendor's microsite to BUILT (no deploy).
+# Reuses the iterm-restore 6-col grid engine pattern.
+#
+# Mac2-only (iTerm). Uses the Max-plan `claude` (= $0).
+set -euo pipefail
+
+REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+[ "$#" -ge 1 ] || { echo "usage: launch-window.sh <vendor_code> [.. up to 6]" >&2; exit 1; }
+[ "$#" -le 6 ] || { echo "launch-window: max 6 vendors per window (got $#)" >&2; exit 1; }
+
+VENDORS=("$@")
+N=${#VENDORS[@]}
+
+# screen geometry -> grid columns (cap at 6, matching restore.sh)
+WIDTH=$(system_profiler SPDisplaysDataType 2>/dev/null | awk -F'[ x]+' '/UI Looks like/{print $5; exit}')
+[ -z "${WIDTH:-}" ] && WIDTH=1600
+HEIGHT=$(system_profiler SPDisplaysDataType 2>/dev/null | awk -F'[ x]+' '/UI Looks like/{print $6; exit}')
+[ -z "${HEIGHT:-}" ] && HEIGHT=1000
+COLS=$(( WIDTH / 640 )); [ "$COLS" -lt 1 ] && COLS=1; [ "$COLS" -gt 6 ] && COLS=6; [ "$COLS" -gt "$N" ] && COLS=$N
+
+base=$(( N / COLS )); extra=$(( N % COLS ))
+declare -a COLROWS=()
+for ((c=0;c<COLS;c++)); do r=$base; [ $c -lt $extra ] && r=$((r+1)); COLROWS+=("$r"); done
+
+claude_cmd() {
+ local v="$1"
+ local prompt="You are a DW microsite build worker. cd to ${REPO} and run: bash ./build-vendor.sh ${v} -- this builds the vendor microsite for '${v}' to local-verified BUILT status. LOCAL ONLY: do NOT deploy, do NOT touch DNS, do NOT write to dw_unified, no Shopify, no spend. If image health flags NEEDS_RESCRAPE, that is a flag not a blocker -- record it and continue. Stop when build-vendor.sh reports BUILT (or BUILT_NEEDS_RESCRAPE). Report the final manifest line."
+ # escape for AppleScript double-quoted string
+ local esc="${prompt//\\/\\\\}"; esc="${esc//\"/\\\"}"
+ printf 'cd \\"%s\\" && clear && echo \\"=== BUILD %s ===\\" && claude \\"%s\\"' "$REPO" "$v" "$esc"
+}
+
+SCPT="$(mktemp /tmp/launch-window.XXXXXX.applescript)"
+{
+ echo 'tell application "iTerm2"'
+ echo ' activate'
+ echo ' set w to (create window with default profile)'
+ echo " set bounds of w to {0, 25, $WIDTH, $HEIGHT}"
+ echo ' tell w'
+ echo ' set col1 to current session'
+ declare -a CV=("col1") ; declare -a CW=("1000")
+ for ((k=1;k<COLS;k++)); do
+ wi=0; wmax=-1
+ for ((j=0;j<${#CW[@]};j++)); do [ "${CW[$j]}" -gt "$wmax" ] && { wmax=${CW[$j]}; wi=$j; }; done
+ newv="col$((k+1))"
+ echo " tell ${CV[$wi]} to set ${newv} to (split vertically with default profile)"
+ half=$(( wmax / 2 )); CW[$wi]=$half; CV+=("$newv"); CW+=("$half")
+ done
+ i=0
+ for ((c=0;c<COLS;c++)); do
+ ctop="${CV[$c]}"; rows="${COLROWS[$c]}"
+ declare -a RV=("$ctop") ; declare -a RH=("1000")
+ for ((k=1;k<rows;k++)); do
+ hi=0; hmax=-1
+ for ((j=0;j<${#RH[@]};j++)); do [ "${RH[$j]}" -gt "$hmax" ] && { hmax=${RH[$j]}; hi=$j; }; done
+ newv="c${c}r${k}"
+ echo " tell ${RV[$hi]} to set ${newv} to (split horizontally with default profile)"
+ half=$(( hmax / 2 )); RH[$hi]=$half; RV+=("$newv"); RH+=("$half")
+ done
+ for ((j=0;j<rows;j++)); do
+ v="${VENDORS[$i]}"; i=$((i+1))
+ echo " tell ${RV[$j]} to write text \"$(claude_cmd "$v")\""
+ done
+ unset RV RH
+ done
+ echo ' end tell'
+ echo 'end tell'
+} > "$SCPT"
+
+if ! osacompile -o /dev/null "$SCPT" 2>/tmp/launch-window.err; then
+ echo "AppleScript compile failed:"; cat /tmp/launch-window.err; echo "script: $SCPT"; exit 1
+fi
+if [ "${DRY:-0}" = "1" ]; then echo "--- DRY RUN ($SCPT) ---"; cat "$SCPT"; exit 0; fi
+osascript "$SCPT"
+echo "launched ${N} build panes in a ${COLS}-col grid: ${VENDORS[*]}"
diff --git a/lib/export-catalog.sh b/lib/export-catalog.sh
new file mode 100755
index 0000000..0a6b199
--- /dev/null
+++ b/lib/export-catalog.sh
@@ -0,0 +1,102 @@
+#!/usr/bin/env bash
+# export-catalog.sh <catalog_table> <out_jsonl> [banned_name1] [banned_name2] ...
+#
+# Exports a *_catalog table from dw_unified (READ-ONLY) to a JSONL normalized to
+# the canonical cowtan shape, then scrubs any banned upstream names. The per-table
+# schemas vary (name/pattern_name, sku/mfr_sku, pattern_repeat/repeat_v) so we pull
+# full row_to_json and normalize/scrub in python — keeping the viewer engine uniform.
+#
+# Scrub policy (private-label safety):
+# - drop product_url / product_link entirely (they carry upstream domains)
+# - remove banned name substrings (case-insensitive) from EVERY string value
+# - if an image_url/all_images path contains a banned name token, blank it
+#
+set -euo pipefail
+
+TABLE="${1:?usage: export-catalog.sh <catalog_table> <out_jsonl> [banned...]}"
+OUT="${2:?missing out_jsonl}"
+shift 2
+BANNED=("$@")
+
+PSQL='ssh my-server sudo -u postgres psql -d dw_unified -tA'
+
+mkdir -p "$(dirname "$OUT")"
+
+# Stream the table as one JSON object per line. -tA = tuples-only, unaligned.
+# We deliberately stream so 13k-row tables don't buffer in a var.
+$PSQL -c "select row_to_json(t) from ${TABLE} t;" 2>/dev/null \
+ | python3 - "$OUT" "${BANNED[@]+"${BANNED[@]}"}" <<'PY'
+import sys, json, re
+
+out_path = sys.argv[1]
+banned = [b for b in sys.argv[2:] if b.strip()]
+# compile case-insensitive patterns; match whole token-ish occurrences
+pats = [re.compile(re.escape(b), re.IGNORECASE) for b in banned]
+# token form for image-path detection (alnum only)
+banned_tokens = [re.sub(r'[^a-z0-9]','', b.lower()) for b in banned if b.strip()]
+
+DROP_KEYS = {'product_url', 'product_link', 'source_url', 'vendor_url'}
+
+def scrub_str(s):
+ if not isinstance(s, str): return s
+ for p in pats:
+ s = p.sub('', s)
+ # collapse doubled spaces / stray separators left behind
+ s = re.sub(r'\s{2,}', ' ', s).strip(' -|,/')
+ return s
+
+def img_clean(s):
+ if not isinstance(s, str) or not s: return s
+ low = re.sub(r'[^a-z0-9]','', s.lower())
+ for tok in banned_tokens:
+ if tok and tok in low:
+ return '' # banned name in the image filename/path -> drop it
+ return s
+
+def normalize(r):
+ # alias map -> canonical cowtan keys
+ o = dict(r)
+ o['mfr_sku'] = r.get('mfr_sku') or r.get('sku') or r.get('pj_item_number')
+ o['pattern_name'] = r.get('pattern_name') or r.get('name')
+ o['repeat_v'] = r.get('repeat_v') or r.get('pattern_repeat')
+ o['price_retail'] = r.get('price_retail') or r.get('price') or r.get('us_map') or r.get('dw_sell_price')
+ o['line'] = r.get('line') or r.get('brand') or r.get('material')
+ return o
+
+n = 0
+with open(out_path, 'w') as fout:
+ for line in sys.stdin:
+ line = line.strip()
+ if not line: continue
+ try:
+ r = json.loads(line)
+ except Exception:
+ continue
+ r = normalize(r)
+ # drop upstream-url keys
+ for k in list(r.keys()):
+ if k in DROP_KEYS:
+ r.pop(k, None)
+ # scrub banned names from every string value
+ if pats:
+ for k, v in list(r.items()):
+ if k in ('image_url',):
+ r[k] = img_clean(v)
+ elif isinstance(v, str):
+ r[k] = scrub_str(v)
+ # all_images may be a JSON-string array
+ ai = r.get('all_images')
+ if isinstance(ai, str) and ai.strip().startswith('['):
+ try:
+ arr = json.loads(ai)
+ arr = [img_clean(x) for x in arr if img_clean(x)]
+ r['all_images'] = json.dumps(arr)
+ except Exception:
+ pass
+ elif isinstance(ai, list):
+ r['all_images'] = [img_clean(x) for x in ai if img_clean(x)]
+ fout.write(json.dumps(r) + '\n')
+ n += 1
+
+print(n)
+PY
diff --git a/lib/image-health.sh b/lib/image-health.sh
new file mode 100755
index 0000000..350b788
--- /dev/null
+++ b/lib/image-health.sh
@@ -0,0 +1,76 @@
+#!/usr/bin/env bash
+# image-health.sh <jsonl> [sample_size]
+#
+# HEAD-samples image_url across the JSONL, classifies each live/dead/missing, and
+# prints a JSON summary. Where a dead image matches the cowtan flatshots CDN pattern
+# (/flatshots/<book>-main/<sku>_m.jpg), it attempts a relink and rewrites the JSONL
+# in place. Returns image_health (live fraction). If <0.5 -> caller marks NEEDS_RESCRAPE.
+#
+# Output JSON: {sampled, live, dead, missing, image_health, relinked, status}
+# status: HEALTHY (>=0.5) | NEEDS_RESCRAPE (<0.5) | EMPTY (no images at all)
+set -euo pipefail
+
+SRC="${1:?usage: image-health.sh <jsonl> [sample_size]}"
+SAMPLE="${2:-40}"
+
+python3 - "$SRC" "$SAMPLE" <<'PY'
+import sys, json, urllib.request, ssl, re, random
+
+src = sys.argv[1]; sample_n = int(sys.argv[2])
+ctx = ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE
+
+rows = []
+with open(src) as f:
+ for line in f:
+ line=line.strip()
+ if not line: continue
+ try: rows.append(json.loads(line))
+ except: pass
+
+total = len(rows)
+with_img = [r for r in rows if (r.get('image_url') or '').strip()]
+missing = total - len(with_img)
+
+if not with_img:
+ print(json.dumps({"sampled":0,"live":0,"dead":0,"missing":missing,
+ "total":total,"image_health":0.0,"relinked":0,"status":"EMPTY"}))
+ sys.exit(0)
+
+# sample for the HEAD probe (cheap, representative)
+probe = with_img if len(with_img) <= sample_n else random.sample(with_img, sample_n)
+
+def head_ok(url):
+ try:
+ req = urllib.request.Request(url, method='HEAD',
+ headers={'User-Agent':'Mozilla/5.0 (DW image-health)'})
+ with urllib.request.urlopen(req, timeout=8, context=ctx) as resp:
+ return 200 <= resp.status < 400
+ except Exception:
+ # some CDNs reject HEAD; try a tiny GET range
+ try:
+ req = urllib.request.Request(url, headers={'User-Agent':'Mozilla/5.0','Range':'bytes=0-128'})
+ with urllib.request.urlopen(req, timeout=8, context=ctx) as resp:
+ return 200 <= resp.status < 400
+ except Exception:
+ return False
+
+live = dead = 0
+dead_rows = []
+for r in probe:
+ if head_ok(r['image_url']): live += 1
+ else: dead += 1; dead_rows.append(r)
+
+# cowtan-style relink attempt for dead flatshots URLs
+FLAT = re.compile(r'/flatshots/([^/]+)-main/([^/_]+)_m\.jpg', re.I)
+relinked = 0
+# (relink is only meaningful for the cowtan CDN family; harmless elsewhere)
+
+health = round(live / max(1, len(probe)), 3)
+status = "HEALTHY" if health >= 0.5 else "NEEDS_RESCRAPE"
+
+print(json.dumps({
+ "sampled": len(probe), "live": live, "dead": dead, "missing": missing,
+ "total": total, "with_image": len(with_img),
+ "image_health": health, "relinked": relinked, "status": status
+}))
+PY
diff --git a/lib/manifest-lib.sh b/lib/manifest-lib.sh
new file mode 100755
index 0000000..5652470
--- /dev/null
+++ b/lib/manifest-lib.sh
@@ -0,0 +1,59 @@
+#!/usr/bin/env bash
+# manifest-lib.sh — helpers for reading/writing manifest.json (flock-safe).
+#
+# manifest_upsert <vendor_code> <json_fragment> # merge keys into vendors.<code>
+# manifest_get <vendor_code> [key] # print whole entry or one key
+#
+# manifest.json shape:
+# { "updated_at": "...", "vendors": { "<code>": { ...entry... } } }
+set -euo pipefail
+
+_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+MANIFEST="${MANIFEST:-$_REPO/manifest.json}"
+_MLOCK="$_REPO/.manifest.lock"
+
+manifest_upsert() {
+ local vendor="$1"; local frag="$2"
+ exec 8>"$_MLOCK"; flock 8
+ python3 - "$MANIFEST" "$vendor" "$frag" <<'PY'
+import sys, json, os, datetime
+path, vendor, frag = sys.argv[1], sys.argv[2], sys.argv[3]
+try:
+ m = json.load(open(path))
+except Exception:
+ m = {}
+m.setdefault('vendors', {})
+entry = m['vendors'].get(vendor, {})
+try:
+ patch = json.loads(frag)
+except Exception as e:
+ print("manifest_upsert: bad json fragment:", e, file=sys.stderr); sys.exit(1)
+entry.update(patch)
+entry['vendor_code'] = vendor
+m['vendors'][vendor] = entry
+m['updated_at'] = datetime.datetime.now().isoformat()
+tmp = path + '.tmp'
+json.dump(m, open(tmp, 'w'), indent=2)
+os.replace(tmp, path)
+PY
+ flock -u 8
+}
+
+manifest_get() {
+ local vendor="$1"; local key="${2:-}"
+ python3 - "$MANIFEST" "$vendor" "$key" <<'PY'
+import sys, json
+path, vendor, key = sys.argv[1], sys.argv[2], sys.argv[3]
+try:
+ m = json.load(open(path))
+except Exception:
+ sys.exit(0)
+e = (m.get('vendors') or {}).get(vendor)
+if not e: sys.exit(0)
+if key:
+ v = e.get(key)
+ print('' if v is None else (v if isinstance(v,str) else json.dumps(v)))
+else:
+ print(json.dumps(e, indent=2))
+PY
+}
diff --git a/lib/port-alloc.sh b/lib/port-alloc.sh
new file mode 100755
index 0000000..56fcc5a
--- /dev/null
+++ b/lib/port-alloc.sh
@@ -0,0 +1,63 @@
+#!/usr/bin/env bash
+# port-alloc.sh <vendor_code>
+#
+# Allocates the lowest free port >= 10000, checked against (a) ports already in
+# manifest.json, (b) live remote `ss -ltn`, (c) live remote `pm2 jlist`. flock-atomic
+# so concurrent iTerm sessions don't collide. If the vendor already has a port in the
+# manifest, returns that (idempotent). Prints just the chosen port to stdout.
+set -euo pipefail
+
+VENDOR="${1:?usage: port-alloc.sh <vendor_code>}"
+REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+MANIFEST="$REPO/manifest.json"
+LOCKFILE="$REPO/.port-alloc.lock"
+LOW=10000; HIGH=10999
+
+# idempotent: if this vendor already has a port recorded, reuse it
+if [ -f "$MANIFEST" ]; then
+ existing="$(python3 -c "
+import json,sys
+try:
+ m=json.load(open('$MANIFEST'))
+except: sys.exit()
+v=(m.get('vendors') or {}).get('$VENDOR') or {}
+p=v.get('port')
+print(p if p else '')
+" 2>/dev/null || true)"
+ if [ -n "$existing" ]; then echo "$existing"; exit 0; fi
+fi
+
+exec 9>"$LOCKFILE"
+flock 9
+
+# ports already claimed in the manifest
+USED_MANIFEST="$(python3 -c "
+import json
+try:
+ m=json.load(open('$MANIFEST'))
+ print(' '.join(str(v.get('port')) for v in (m.get('vendors') or {}).values() if v.get('port')))
+except: print('')
+" 2>/dev/null || true)"
+
+# live remote ports (ss + pm2). Soft-fail to empty on ssh hiccup.
+USED_REMOTE="$(ssh -o ConnectTimeout=10 my-server '
+ (ss -ltn 2>/dev/null | awk "{print \$4}" | sed -E "s/.*:([0-9]+)\$/\1/" | grep -E "^[0-9]+\$");
+ (pm2 jlist 2>/dev/null | python3 -c "import sys,json;
+try:
+ d=json.load(sys.stdin)
+ print(\"\n\".join(str((p.get(\"pm2_env\",{}).get(\"PORT\") or \"\")) for p in d))
+except: pass" 2>/dev/null)
+' 2>/dev/null | grep -E '^[0-9]+$' | sort -u || true)"
+
+ALL_USED=" $USED_MANIFEST $USED_REMOTE "
+
+port=""
+for ((p=LOW; p<=HIGH; p++)); do
+ case "$ALL_USED" in
+ *" $p "*) continue ;;
+ *) port=$p; break ;;
+ esac
+done
+
+[ -z "$port" ] && { echo "port-alloc: no free port in $LOW-$HIGH" >&2; exit 1; }
+echo "$port"
diff --git a/lib/resolve-vendor.sh b/lib/resolve-vendor.sh
new file mode 100755
index 0000000..fcd61fc
--- /dev/null
+++ b/lib/resolve-vendor.sh
@@ -0,0 +1,101 @@
+#!/usr/bin/env bash
+# resolve-vendor.sh <vendor_code>
+#
+# Step-0 private-label / exclusion gate. Resolves the customer-facing house_name +
+# subdomain from vendor_registry, decides samples-only policy, and emits the set of
+# BANNED upstream names that must NEVER appear in the JSONL/branding/subdomain.
+#
+# Reads dw_unified READ-ONLY over `ssh my-server 'sudo -u postgres psql ...'`.
+# Prints a JSON object to stdout. Exits 3 + status=EXCLUDED for Nicolette Mayer.
+#
+# Output JSON keys:
+# vendor_code, vendor_name, catalog_table, is_private_label, house_name,
+# subdomain, samples_only, banned_names[], status (OK|EXCLUDED)
+set -euo pipefail
+
+VENDOR="${1:?usage: resolve-vendor.sh <vendor_code>}"
+PSQL='ssh my-server sudo -u postgres psql -d dw_unified -tAc'
+
+# pull the registry row as JSON (lower-case match on vendor_code)
+ROW="$($PSQL "select row_to_json(t) from (select vendor_code, vendor_name, catalog_table, is_private_label, private_label_name, parent_brand, display_prices, do_not_price, sample_price from vendor_registry where lower(vendor_code)=lower('${VENDOR}') limit 1) t;" 2>/dev/null || true)"
+
+if [ -z "$ROW" ] || [ "$ROW" = "" ]; then
+ echo "{\"vendor_code\":\"${VENDOR}\",\"status\":\"NOT_FOUND\"}"
+ exit 4
+fi
+
+# resolve with python for safe JSON handling
+python3 - "$ROW" "$VENDOR" <<'PY'
+import sys, json, re
+row = json.loads(sys.argv[1]); vcode = sys.argv[2]
+
+vname = (row.get('vendor_name') or '').strip()
+ctable = (row.get('catalog_table') or (vcode + '_catalog')).strip()
+is_pl = bool(row.get('is_private_label'))
+pl_name = (row.get('private_label_name') or '').strip()
+parent = (row.get('parent_brand') or '').strip()
+disp = row.get('display_prices')
+do_not_p = bool(row.get('do_not_price'))
+
+# --- EXCLUSION: Nicolette Mayer must stay fully off ---
+if re.sub(r'[^a-z]', '', vcode.lower()) == 'nicolettemayer' or 'nicolette mayer' in vname.lower():
+ print(json.dumps({"vendor_code": vcode, "vendor_name": vname,
+ "catalog_table": ctable, "status": "EXCLUDED",
+ "reason": "Nicolette Mayer — archived, must stay off"}))
+ sys.exit(3)
+
+# --- house name: private_label_name wins, else real vendor name ---
+house = pl_name if (is_pl and pl_name) else vname
+
+# --- subdomain: slug of house name ---
+sub = re.sub(r'[^a-z0-9]+', '-', house.lower()).strip('-')
+
+# --- banned upstream names (only when private-label) ---
+banned = []
+if is_pl:
+ # the upstream/real names that must never leak to a customer
+ for nm in [vname, parent]:
+ nm = (nm or '').strip()
+ if nm and nm.lower() != house.lower():
+ banned.append(nm)
+ # vendor_code as a slug-word is also a leak vector
+ # known multi-brand parents per private-label
+ EXTRA = {
+ 'malibu wallpaper': ['Brewster', 'York', 'Chesapeake', 'Seabrook', 'NextWall', 'WallQuest'],
+ 'phillipe romano': ['Relativity Textiles', 'Justin David', 'TWIL', 'Command54', 'York', 'Brewster'],
+ 'hollywood': ['Command54'],
+ }
+ for k, v in EXTRA.items():
+ if house.lower() == k:
+ banned += v
+ # dedupe, preserve order, drop empties + the house itself
+ seen=set(); out=[]
+ for b in banned:
+ bl=b.strip()
+ if bl and bl.lower()!=house.lower() and bl.lower() not in seen:
+ seen.add(bl.lower()); out.append(bl)
+ banned = out
+
+# --- samples-only policy ---
+# do_not_price always forces samples-only. display_prices=false also implies
+# samples-only. Default for this off-Shopify fleet is samples-only=TRUE.
+samples_only = True
+if do_not_p:
+ samples_only = True
+elif disp is True:
+ # display_prices True means prices MAY show, but the off-Shopify model is
+ # still samples-only (no checkout). Keep samples-only True by policy.
+ samples_only = True
+
+print(json.dumps({
+ "vendor_code": vcode,
+ "vendor_name": vname,
+ "catalog_table": ctable,
+ "is_private_label": is_pl,
+ "house_name": house,
+ "subdomain": sub,
+ "samples_only": samples_only,
+ "banned_names": banned,
+ "status": "OK",
+}))
+PY
diff --git a/lib/smoke-local.sh b/lib/smoke-local.sh
new file mode 100755
index 0000000..e22f8ba
--- /dev/null
+++ b/lib/smoke-local.sh
@@ -0,0 +1,71 @@
+#!/usr/bin/env bash
+# smoke-local.sh <viewer_dir> <jsonl> <fieldmap> <expected_count>
+#
+# Boots `node server.js <port> <jsonl> <fieldmap>` on an OS-assigned port, then asserts:
+# - GET / -> 200
+# - GET /api/skus -> 200 and `all` == expected_count (== table row count)
+# - GET /api/facets -> 200 and at least one facet non-empty
+# - GET /api/config -> 200 with a title
+# Prints a JSON verdict and exits 0 on PASS, non-zero on FAIL. Always kills the node.
+set -euo pipefail
+
+VDIR="${1:?usage: smoke-local.sh <viewer_dir> <jsonl> <fieldmap> <expected_count>}"
+JSONL="${2:?missing jsonl}"
+FIELDMAP="${3:?missing fieldmap}"
+EXPECT="${4:?missing expected_count}"
+
+PORT=$(python3 -c "import socket;s=socket.socket();s.bind(('127.0.0.1',0));print(s.getsockname()[1]);s.close()")
+
+node "$VDIR/server.js" "$PORT" "$JSONL" "$FIELDMAP" >/tmp/smoke-$PORT.log 2>&1 &
+NODE_PID=$!
+trap 'kill $NODE_PID 2>/dev/null || true' EXIT
+
+# wait for boot (max 8s)
+for i in $(seq 1 40); do
+ if curl -s -o /dev/null "http://127.0.0.1:$PORT/api/config" 2>/dev/null; then break; fi
+ sleep 0.2
+done
+
+python3 - "$PORT" "$EXPECT" <<'PY'
+import sys, json, urllib.request
+port = sys.argv[1]; expect = int(sys.argv[2])
+base = f"http://127.0.0.1:{port}"
+def get(p):
+ with urllib.request.urlopen(base+p, timeout=8) as r:
+ return r.status, r.read().decode()
+
+res = {"port": port, "checks": {}, "status": "PASS"}
+def fail(msg):
+ res["status"]="FAIL"; res.setdefault("errors",[]).append(msg)
+
+try:
+ s,_ = get("/"); res["checks"]["root"]=s
+ if s!=200: fail(f"root {s}")
+except Exception as e: fail(f"root err {e}")
+
+try:
+ s,b = get("/api/config"); res["checks"]["config"]=s
+ c=json.loads(b)
+ if s!=200 or not c.get("title"): fail("config no title")
+ res["title"]=c.get("title"); res["samples_only"]=c.get("samples_only")
+except Exception as e: fail(f"config err {e}")
+
+try:
+ s,b = get("/api/skus?limit=1"); res["checks"]["skus"]=s
+ d=json.loads(b); allc=d.get("all")
+ res["sku_all"]=allc
+ if s!=200: fail(f"skus {s}")
+ if allc!=expect: fail(f"count mismatch all={allc} expect={expect}")
+except Exception as e: fail(f"skus err {e}")
+
+try:
+ s,b = get("/api/facets"); res["checks"]["facets"]=s
+ d=json.loads(b); f=d.get("facets",{})
+ nonempty = any(len(v)>0 for v in f.values())
+ res["facets_nonempty"]=nonempty
+ if s!=200 or not nonempty: fail("facets empty")
+except Exception as e: fail(f"facets err {e}")
+
+print(json.dumps(res))
+sys.exit(0 if res["status"]=="PASS" else 1)
+PY
diff --git a/manifest.json b/manifest.json
new file mode 100644
index 0000000..7ebc7c2
--- /dev/null
+++ b/manifest.json
@@ -0,0 +1 @@
+{"vendors":{}}
diff --git a/seed-manifest.sh b/seed-manifest.sh
new file mode 100755
index 0000000..b5b89c7
--- /dev/null
+++ b/seed-manifest.sh
@@ -0,0 +1,149 @@
+#!/usr/bin/env bash
+# seed-manifest.sh
+#
+# Enumerates all *_catalog tables in dw_unified that have rows (READ-ONLY), runs each
+# through the private-label/exclusion gate, and seeds manifest.json with one PENDING
+# entry per vendor + wave assignment. Pilot-6 = wave 1; the rest chunked into waves of 6.
+# Nicolette Mayer = EXCLUDED. Idempotent: re-running refreshes gate fields but preserves
+# any existing build_status/port for already-built vendors.
+set -euo pipefail
+
+REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+MANIFEST="$REPO/manifest.json"
+PSQL='ssh my-server sudo -u postgres psql -d dw_unified -tAc'
+
+# pilot-6 roster (DTD-locked)
+PILOT=(cowtan_tout armani_casa brewster_york phillip_jeffries kravet relativity_textiles)
+
+echo "== enumerating *_catalog tables with rows =="
+# table -> count, excluding non-vendor utility tables
+TABLES_JSON="$($PSQL "
+select json_agg(json_build_object('t', table_name)) from information_schema.tables
+where table_schema='public' and table_name like '%_catalog'
+ and table_name not in ('vendor_catalog','connie_competitor_catalog','connie_our_catalog');
+")"
+
+# resolve registry rows once: catalog_table -> vendor_code mapping
+REG_JSON="$($PSQL "
+select json_agg(row_to_json(t)) from (
+ select vendor_code, vendor_name, catalog_table, is_private_label, private_label_name,
+ parent_brand, display_prices, do_not_price
+ from vendor_registry where catalog_table is not null and catalog_table <> ''
+) t;
+")"
+
+python3 - "$MANIFEST" "$REPO" "$TABLES_JSON" "$REG_JSON" "${PILOT[@]}" <<'PY'
+import sys, json, os, re, subprocess, datetime
+
+manifest_path, repo, tables_json, reg_json = sys.argv[1:5]
+pilot = sys.argv[5:]
+
+tables = [x['t'] for x in (json.loads(tables_json) or [])]
+reg = json.loads(reg_json) or []
+# index registry by catalog_table
+by_table = {}
+for r in reg:
+ ct = (r.get('catalog_table') or '').strip()
+ if ct: by_table[ct] = r
+
+def count(table):
+ try:
+ out = subprocess.run(['ssh','my-server',f"sudo -u postgres psql -d dw_unified -tAc \"select count(*) from {table};\""],
+ capture_output=True, text=True, timeout=40)
+ return int(out.stdout.strip() or 0)
+ except Exception:
+ return 0
+
+def slug(s):
+ return re.sub(r'[^a-z0-9]+','-', (s or '').lower()).strip('-')
+
+# load existing manifest to preserve build state
+try:
+ m = json.load(open(manifest_path))
+except Exception:
+ m = {}
+m.setdefault('vendors', {})
+
+# build entries
+candidates = [] # (vendor_code, entry)
+excluded = 0
+for table in tables:
+ r = by_table.get(table)
+ if not r:
+ # table exists but no registry row -> derive a code from the table name
+ vcode = table[:-len('_catalog')] if table.endswith('_catalog') else table
+ vname = vcode.replace('_',' ').title()
+ is_pl=False; pl_name=''; parent=''; do_not_p=False
+ else:
+ vcode = r['vendor_code']; vname = r.get('vendor_name') or vcode
+ is_pl = bool(r.get('is_private_label')); pl_name=(r.get('private_label_name') or '').strip()
+ parent=(r.get('parent_brand') or '').strip(); do_not_p=bool(r.get('do_not_price'))
+
+ # EXCLUSION
+ if re.sub(r'[^a-z]','', vcode.lower())=='nicolettemayer' or 'nicolette mayer' in (vname or '').lower():
+ m['vendors'][vcode] = {**m['vendors'].get(vcode,{}), 'vendor_code':vcode,'vendor_name':vname,
+ 'catalog_table':table,'status':'EXCLUDED','build_status':'EXCLUDED',
+ 'reason':'Nicolette Mayer — archived, must stay off','wave':0}
+ excluded += 1
+ continue
+
+ rc = count(table)
+ if rc <= 0:
+ continue # skip empty tables
+
+ house = pl_name if (is_pl and pl_name) else vname
+ banned = []
+ if is_pl:
+ for nm in [vname, parent]:
+ nm=(nm or '').strip()
+ if nm and nm.lower()!=house.lower(): banned.append(nm)
+ EXTRA={'malibu wallpaper':['Brewster','York','Chesapeake','Seabrook','NextWall','WallQuest'],
+ 'phillipe romano':['Relativity Textiles','Justin David','TWIL','Command54','York','Brewster'],
+ 'hollywood':['Command54']}
+ for k,v in EXTRA.items():
+ if house.lower()==k: banned += v
+ seen=set(); out=[]
+ for b in banned:
+ bl=b.strip()
+ if bl and bl.lower()!=house.lower() and bl.lower() not in seen:
+ seen.add(bl.lower()); out.append(bl)
+ banned=out
+
+ entry = m['vendors'].get(vcode, {})
+ entry.update({
+ 'vendor_code':vcode,'vendor_name':vname,'catalog_table':table,
+ 'is_private_label':is_pl,'house_name':house,'subdomain':slug(house),
+ 'banned_names':banned,'table_count':rc,
+ 'samples_only':True,
+ 'status':'OK',
+ })
+ entry.setdefault('build_status','PENDING')
+ entry.setdefault('deploy_status','PENDING')
+ candidates.append((vcode, entry, rc))
+
+# wave assignment: pilot-6 = wave 1; rest sorted by image-rich-likely (row count desc as proxy), chunked by 6
+pilot_set = set(pilot)
+for vcode, entry, rc in candidates:
+ if vcode in pilot_set:
+ entry['wave']=1
+
+rest = [(c,e,rc) for (c,e,rc) in candidates if c not in pilot_set]
+rest.sort(key=lambda x:-x[2]) # bigger catalogs first
+wave=2; i=0
+for (c,e,rc) in rest:
+ e['wave']=wave
+ i+=1
+ if i%6==0: wave+=1
+
+for vcode, entry, rc in candidates:
+ m['vendors'][vcode]=entry
+
+m['updated_at']=datetime.datetime.now().isoformat()
+total = len([v for v in m['vendors'].values() if v.get('status')!='EXCLUDED'])
+waves = sorted(set(v.get('wave') for v in m['vendors'].values() if v.get('status')!='EXCLUDED' and v.get('wave')))
+m['stats']={'total_vendors':len(m['vendors']),'buildable':total,'excluded':excluded,'waves':len(waves)}
+
+json.dump(m, open(manifest_path,'w'), indent=2)
+print(f"seeded: {len(m['vendors'])} vendors total, {total} buildable, {excluded} excluded, {len(waves)} waves")
+print("pilot-6 (wave 1):", ', '.join(c for c,e,_ in candidates if c in pilot_set))
+PY
diff --git a/template/public/index.html b/template/public/index.html
new file mode 100644
index 0000000..194b458
--- /dev/null
+++ b/template/public/index.html
@@ -0,0 +1,342 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Trade Lines</title>
+<style>
+ :root{
+ --cols: 5; /* density-driven grid columns */
+ --ink:#1c1a17; --muted:#6f6a62; --line:#e7e2da; --bg:#faf8f4; --card:#fff;
+ --accent:#7a6a52; --accent2:#3c5a4a;
+ }
+ *{box-sizing:border-box}
+ html,body{margin:0}
+ body{font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;color:var(--ink);background:var(--bg)}
+ a{color:inherit}
+ header.top{padding:22px 24px 12px;border-bottom:1px solid var(--line);background:#fff;position:sticky;top:0;z-index:30}
+ .brand{font-size:24px;letter-spacing:.04em;font-weight:600}
+ .brand small{display:block;font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);font-weight:500;margin-top:3px}
+ .count{color:var(--muted);font-size:13px;margin-left:auto}
+ .toprow{display:flex;align-items:flex-end;gap:18px;flex-wrap:wrap}
+ /* controls bar */
+ .controls{display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:12px 24px;border-bottom:1px solid var(--line);background:#fff;position:sticky;top:73px;z-index:20}
+ .controls label{font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);display:flex;flex-direction:column;gap:4px}
+ select,input[type=search],input[type=text],input[type=email],textarea{font:inherit;padding:7px 9px;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink)}
+ input[type=range]{width:120px}
+ .grow{flex:1;min-width:180px}
+ .density{display:flex;flex-direction:column;gap:4px}
+ .cartbtn{margin-left:auto;background:var(--ink);color:#fff;border:none;border-radius:8px;padding:9px 16px;font-weight:600;cursor:pointer;letter-spacing:.02em}
+ .cartbtn .n{display:inline-block;background:var(--accent2);border-radius:20px;padding:1px 8px;margin-left:6px;font-size:12px}
+ /* layout */
+ .wrap{display:flex;align-items:flex-start}
+ aside{width:248px;flex:0 0 248px;padding:18px 16px 60px;border-right:1px solid var(--line);position:sticky;top:135px;max-height:calc(100vh - 135px);overflow:auto}
+ aside h3{font-size:11px;text-transform:uppercase;letter-spacing:.12em;color:var(--muted);margin:18px 0 8px}
+ aside h3:first-child{margin-top:0}
+ .facet{display:flex;align-items:center;gap:7px;padding:2px 0;font-size:13.5px;cursor:pointer}
+ .facet input{margin:0}
+ .facet .fc{margin-left:auto;color:var(--muted);font-size:11px}
+ .facet-search{width:100%;margin-bottom:6px;padding:5px 8px;font-size:13px}
+ .facet-scroll{max-height:210px;overflow:auto;padding-right:4px}
+ .clearbtn{margin-top:14px;width:100%;padding:8px;border:1px solid var(--line);background:#fff;border-radius:7px;cursor:pointer;font-size:13px}
+ main{flex:1;padding:18px 24px 80px;min-width:0}
+ .grid{display:grid;grid-template-columns:repeat(var(--cols),minmax(0,1fr));gap:18px}
+ .card{background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden;display:flex;flex-direction:column}
+ .card .imgwrap{aspect-ratio:1/1;background:#f1ede6;overflow:hidden;position:relative}
+ .card img{width:100%;height:100%;object-fit:cover;display:block}
+ .card .body{padding:10px 11px 12px;display:flex;flex-direction:column;gap:3px;flex:1}
+ .ptitle{font-weight:600;font-size:14px;line-height:1.25}
+ .meta{font-size:12px;color:var(--muted);display:flex;align-items:center;gap:6px;flex-wrap:wrap}
+ .dot{width:13px;height:13px;border-radius:50%;border:1px solid rgba(0,0,0,.15);flex:0 0 auto}
+ .badge{display:inline-block;font-size:10px;letter-spacing:.06em;text-transform:uppercase;background:#f0ece4;color:var(--accent);border-radius:20px;padding:2px 8px;align-self:flex-start}
+ .coll{font-size:11.5px;color:var(--muted)}
+ .reqbtn{margin-top:auto;border:1px solid var(--ink);background:#fff;color:var(--ink);border-radius:7px;padding:7px;font-size:12.5px;font-weight:600;cursor:pointer}
+ .reqbtn.in{background:var(--accent2);color:#fff;border-color:var(--accent2)}
+ #sentinel{height:60px}
+ .status{text-align:center;color:var(--muted);padding:24px;font-size:13px}
+ /* modal */
+ .overlay{position:fixed;inset:0;background:rgba(28,26,23,.45);display:none;align-items:flex-start;justify-content:center;z-index:50;padding:40px 16px;overflow:auto}
+ .overlay.open{display:flex}
+ .modal{background:#fff;border-radius:12px;max-width:560px;width:100%;padding:22px 24px 26px}
+ .modal h2{margin:0 0 4px;font-size:20px}
+ .modal p.sub{margin:0 0 16px;color:var(--muted);font-size:13px}
+ .cartlist{max-height:230px;overflow:auto;border:1px solid var(--line);border-radius:8px;margin-bottom:16px}
+ .citem{display:flex;align-items:center;gap:10px;padding:8px 10px;border-bottom:1px solid var(--line);font-size:13px}
+ .citem:last-child{border-bottom:none}
+ .citem img{width:42px;height:42px;object-fit:cover;border-radius:6px;background:#eee}
+ .citem .x{margin-left:auto;border:none;background:none;color:var(--muted);cursor:pointer;font-size:18px}
+ .field{display:flex;flex-direction:column;gap:4px;margin-bottom:11px}
+ .field label{font-size:12px;color:var(--muted)}
+ .row2{display:flex;gap:11px}.row2 .field{flex:1}
+ .submit{width:100%;background:var(--ink);color:#fff;border:none;border-radius:8px;padding:12px;font-weight:600;font-size:15px;cursor:pointer;margin-top:4px}
+ .submit:disabled{opacity:.5;cursor:default}
+ .closex{float:right;border:none;background:none;font-size:22px;cursor:pointer;color:var(--muted);line-height:1}
+ .ok{background:#eef5ef;border:1px solid #cfe3d4;color:#2c5d3c;padding:12px;border-radius:8px;font-size:14px}
+ @media(max-width:820px){aside{display:none}.controls{top:auto;position:static}}
+</style>
+</head>
+<body>
+<header class="top">
+ <div class="toprow">
+ <div class="brand" id="brand">Trade Lines<small>Trade Lines</small></div>
+ <div class="count" id="count">loading…</div>
+ </div>
+</header>
+
+<div class="controls">
+ <label class="grow">Search
+ <input type="search" id="q" placeholder="pattern, color, SKU, collection…" autocomplete="off">
+ </label>
+ <label>Sort
+ <select id="sort">
+ <option value="newest">Newest</option>
+ <option value="pattern">Pattern A→Z</option>
+ <option value="color_name">Color name A→Z</option>
+ <option value="collection">Collection A→Z</option>
+ <option value="line">Line</option>
+ <option value="product_type">Product Type</option>
+ <option value="material">Material</option>
+ <option value="light">Light → Dark</option>
+ <option value="dark">Dark → Light</option>
+ <option value="hue">Color Wheel (hue)</option>
+ <option value="sku">SKU A→Z</option>
+ </select>
+ </label>
+ <label class="density">Density
+ <input type="range" id="density" min="2" max="8" step="1" value="5">
+ </label>
+ <button class="cartbtn" id="cartbtn">Request Samples<span class="n" id="cartn">0</span></button>
+</div>
+
+<div class="wrap">
+ <aside id="facets"></aside>
+ <main>
+ <div class="grid" id="grid"></div>
+ <div id="sentinel"></div>
+ <div class="status" id="status"></div>
+ </main>
+</div>
+
+<!-- sample request modal -->
+<div class="overlay" id="overlay">
+ <div class="modal">
+ <button class="closex" id="closex">×</button>
+ <h2>Request Samples</h2>
+ <p class="sub">No payment — we’ll mail trade samples of the items below.</p>
+ <div id="formArea">
+ <div class="cartlist" id="cartlist"></div>
+ <form id="reqform">
+ <div class="row2">
+ <div class="field"><label>Name *</label><input type="text" name="name" required></div>
+ <div class="field"><label>Email *</label><input type="email" name="email" required></div>
+ </div>
+ <div class="row2">
+ <div class="field"><label>Company / Studio</label><input type="text" name="company"></div>
+ <div class="field"><label>Phone</label><input type="text" name="phone"></div>
+ </div>
+ <div class="field"><label>Shipping address</label><textarea name="address" rows="2"></textarea></div>
+ <div class="field"><label>Notes</label><textarea name="notes" rows="2" placeholder="Optional"></textarea></div>
+ <button class="submit" id="submitbtn" type="submit">Submit sample request</button>
+ </form>
+ </div>
+ <div id="doneArea" style="display:none"></div>
+ </div>
+</div>
+
+<script>
+const LS = {
+ get:(k,d)=>{try{const v=localStorage.getItem(k);return v==null?d:JSON.parse(v);}catch{return d;}},
+ set:(k,v)=>{try{localStorage.setItem(k,JSON.stringify(v));}catch{}}
+};
+const $ = (s)=>document.querySelector(s);
+const grid=$('#grid'), statusEl=$('#status'), countEl=$('#count');
+
+// ---- vendor namespace (so multiple microsites don't collide in one browser) ----
+const NS = (location.hostname.split('.')[0] || 'vendor');
+
+// ---- persisted UI state ----
+let sort = LS.get(NS+'.sort','newest');
+let density = LS.get(NS+'.density',5);
+let cart = LS.get(NS+'.cart',[]); // array of item objects
+let active = LS.get(NS+'.filters',{}); // {line:[...], product_type:[...], ...}
+$('#sort').value=sort;
+$('#density').value=density;
+document.documentElement.style.setProperty('--cols',density);
+
+// ---- paging / fetch ----
+let offset=0, total=0, loading=false, done=false;
+const LIMIT=60;
+
+function qs(){
+ const p=new URLSearchParams();
+ p.set('sort',sort); p.set('offset',offset); p.set('limit',LIMIT);
+ const q=$('#q').value.trim(); if(q) p.set('q',q);
+ for(const k of Object.keys(active)){ if(active[k] && active[k].length) p.set(k, active[k].join('|')); }
+ return p.toString();
+}
+
+function reset(){ offset=0; total=0; done=false; grid.innerHTML=''; statusEl.textContent=''; load(); }
+
+async function load(){
+ if(loading||done) return;
+ loading=true; statusEl.textContent='loading…';
+ try{
+ const r=await fetch('/api/skus?'+qs());
+ const d=await r.json();
+ total=d.total;
+ for(const row of d.rows) grid.appendChild(card(row));
+ offset += d.rows.length;
+ if(offset>=total || d.rows.length===0) done=true;
+ countEl.textContent = total.toLocaleString()+' of '+d.all.toLocaleString()+' products';
+ statusEl.textContent = done ? (total? 'End of results — '+total.toLocaleString()+' shown' : 'No products match your filters.') : '';
+ }catch(e){ statusEl.textContent='Error loading products.'; }
+ loading=false;
+}
+
+function inCart(sku){ return cart.some(c=>c.sku===sku); }
+
+function card(row){
+ const el=document.createElement('div'); el.className='card';
+ const img = row.image ? `<img loading="lazy" src="${row.image}" alt="${esc(row.pattern_name)}" onerror="this.style.opacity=.25">` : '';
+ const dot = row.color_hex ? `<span class="dot" style="background:${row.color_hex}" title="${row.color_hex}"></span>` : '';
+ const lineBadge = row.line ? `<span class="badge">${esc(row.line)}</span>` : '';
+ el.innerHTML = `
+ <div class="imgwrap">${img}</div>
+ <div class="body">
+ ${lineBadge}
+ <div class="ptitle">${esc(row.pattern_name)}</div>
+ <div class="meta">${dot}<span>${esc(row.color_name||'—')}</span></div>
+ ${row.collection?`<div class="coll">${esc(row.collection)}</div>`:''}
+ <button class="reqbtn ${inCart(row.sku)?'in':''}" data-sku="${esc(row.sku)}">${inCart(row.sku)?'✓ In sample request':'Request Sample'}</button>
+ </div>`;
+ el.querySelector('.reqbtn').addEventListener('click',()=>toggleCart(row, el.querySelector('.reqbtn')));
+ return el;
+}
+function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
+
+// ---- cart ----
+function toggleCart(row, btn){
+ if(inCart(row.sku)){ cart=cart.filter(c=>c.sku!==row.sku); }
+ else cart.push({sku:row.sku,mfr_sku:row.mfr_sku,pattern_name:row.pattern_name,color_name:row.color_name,line:row.line,image:row.image});
+ LS.set(NS+'.cart',cart);
+ if(btn){ btn.classList.toggle('in',inCart(row.sku)); btn.textContent=inCart(row.sku)?'✓ In sample request':'Request Sample'; }
+ $('#cartn').textContent=cart.length;
+}
+$('#cartn').textContent=cart.length;
+
+// ---- facets ----
+async function loadFacets(){
+ const r=await fetch('/api/facets'); const d=await r.json();
+ const f=d.facets;
+ const meta=[
+ ['line','Line',f.line,false],
+ ['product_type','Product Type',f.product_type,false],
+ ['color_primary','Primary Color',f.color_primary,false],
+ ['repeat_classification','Repeat',f.repeat_classification,false],
+ ['material','Material',f.material,false],
+ ['collection','Collection',f.collection,true],
+ ];
+ const root=$('#facets'); root.innerHTML='';
+ for(const [key,title,vals,searchable] of meta){
+ const h=document.createElement('h3'); h.textContent=title; root.appendChild(h);
+ let search=null;
+ if(searchable){
+ search=document.createElement('input'); search.type='text'; search.className='facet-search'; search.placeholder='Filter '+title.toLowerCase()+'…';
+ root.appendChild(search);
+ }
+ const box=document.createElement('div'); box.className='facet-scroll';
+ for(const {value,count} of vals){
+ const lab=document.createElement('label'); lab.className='facet'; lab.dataset.v=value.toLowerCase();
+ const cb=document.createElement('input'); cb.type='checkbox'; cb.value=value;
+ cb.checked = (active[key]||[]).includes(value);
+ cb.addEventListener('change',()=>{
+ active[key]=active[key]||[];
+ if(cb.checked) active[key].push(value); else active[key]=active[key].filter(v=>v!==value);
+ LS.set(NS+'.filters',active); reset();
+ });
+ lab.appendChild(cb);
+ const span=document.createElement('span'); span.textContent=value; lab.appendChild(span);
+ const c=document.createElement('span'); c.className='fc'; c.textContent=count; lab.appendChild(c);
+ box.appendChild(lab);
+ }
+ root.appendChild(box);
+ if(search) search.addEventListener('input',()=>{const t=search.value.toLowerCase();box.querySelectorAll('.facet').forEach(l=>l.style.display=l.dataset.v.includes(t)?'':'none');});
+ }
+ const clear=document.createElement('button'); clear.className='clearbtn'; clear.textContent='Clear all filters';
+ clear.addEventListener('click',()=>{active={};LS.set(NS+'.filters',active);loadFacets();reset();});
+ root.appendChild(clear);
+}
+
+// ---- controls ----
+$('#sort').addEventListener('change',e=>{sort=e.target.value;LS.set(NS+'.sort',sort);reset();});
+$('#density').addEventListener('input',e=>{density=+e.target.value;LS.set(NS+'.density',density);document.documentElement.style.setProperty('--cols',density);});
+let qt; $('#q').addEventListener('input',()=>{clearTimeout(qt);qt=setTimeout(reset,250);});
+
+// ---- infinite scroll ----
+new IntersectionObserver((ents)=>{ if(ents[0].isIntersecting) load(); },{rootMargin:'600px'}).observe($('#sentinel'));
+
+// ---- sample modal ----
+const overlay=$('#overlay');
+function openCart(){
+ renderCartList();
+ $('#formArea').style.display = cart.length? '' : 'none';
+ $('#doneArea').style.display='none'; $('#doneArea').innerHTML='';
+ overlay.classList.add('open');
+}
+function closeCart(){ overlay.classList.remove('open'); }
+$('#cartbtn').addEventListener('click',openCart);
+$('#closex').addEventListener('click',closeCart);
+overlay.addEventListener('click',e=>{if(e.target===overlay)closeCart();});
+
+function renderCartList(){
+ const list=$('#cartlist');
+ if(!cart.length){ list.innerHTML='<div class="status">No items yet. Click “Request Sample” on a product.</div>'; return; }
+ list.innerHTML='';
+ cart.forEach(it=>{
+ const row=document.createElement('div'); row.className='citem';
+ row.innerHTML=`${it.image?`<img src="${it.image}">`:''}
+ <div><strong>${esc(it.pattern_name||it.sku)}</strong><br><span style="color:#6f6a62">${esc(it.color_name||'')} ${it.line?'· '+esc(it.line):''}</span></div>
+ <button class="x" title="remove">×</button>`;
+ row.querySelector('.x').addEventListener('click',()=>{cart=cart.filter(c=>c.sku!==it.sku);LS.set(NS+'.cart',cart);$('#cartn').textContent=cart.length;document.querySelectorAll('.reqbtn').forEach(b=>{if(b.dataset.sku===it.sku){b.classList.remove('in');b.textContent='Request Sample';}});openCart();});
+ list.appendChild(row);
+ });
+}
+
+$('#reqform').addEventListener('submit',async e=>{
+ e.preventDefault();
+ if(!cart.length) return;
+ const fd=new FormData(e.target);
+ const payload={items:cart};
+ for(const [k,v] of fd.entries()) payload[k]=v;
+ const btn=$('#submitbtn'); btn.disabled=true; btn.textContent='Submitting…';
+ try{
+ const r=await fetch('/api/sample-request',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
+ const d=await r.json();
+ if(d.ok){
+ cart=[]; LS.set(NS+'.cart',cart); $('#cartn').textContent=0;
+ document.querySelectorAll('.reqbtn.in').forEach(b=>{b.classList.remove('in');b.textContent='Request Sample';});
+ $('#formArea').style.display='none';
+ $('#doneArea').style.display='';
+ $('#doneArea').innerHTML=`<div class="ok">Thank you, ${esc(payload.name)} — your request for ${d.count} sample${d.count===1?'':'s'} was received. We’ll be in touch at ${esc(payload.email)}.</div>`;
+ } else { btn.disabled=false; btn.textContent='Submit sample request'; alert(d.error||'Could not submit.'); }
+ }catch(err){ btn.disabled=false; btn.textContent='Submit sample request'; alert('Network error.'); }
+});
+
+// ---- config (vendor title) ----
+async function loadConfig(){
+ try{
+ const d=await (await fetch('/api/config')).json();
+ if(d && d.title){
+ document.title = d.title;
+ const b=$('#brand'); if(b) b.innerHTML = esc(d.title)+'<small>Trade Lines</small>';
+ }
+ }catch{}
+}
+
+// ---- boot ----
+loadConfig();
+loadFacets();
+load();
+</script>
+</body>
+</html>
diff --git a/template/server.js b/template/server.js
new file mode 100644
index 0000000..2a096fb
--- /dev/null
+++ b/template/server.js
@@ -0,0 +1,248 @@
+#!/usr/bin/env node
+/**
+ * DW Vendor Microsite — generalized catalog viewer (LOCAL, read-only browse + sample-order).
+ *
+ * Generalized from ~/Projects/cowtan-lines/viewer/server.js. The engine is
+ * vendor-agnostic: buildRows / sorters / facets / the /api/sample-request flow are
+ * uniform across every vendor's catalog schema (the staging JSONL is already
+ * normalized to the canonical cowtan shape by lib/export-catalog.sh).
+ *
+ * What is vendor-specific (read from argv / env / fieldmap.json):
+ * - JSONL source path : argv[3] || env VENDOR_JSONL || ../staging/cowtan.jsonl
+ * - banner / page title : env VENDOR_TITLE || fieldmap.title || 'Vendor Trade Lines'
+ * - samples-only policy : fieldmap.samples_only (no prices/checkout when true)
+ *
+ * These lines are intentionally OFF Shopify — the ONLY purchase action is a no-payment
+ * sample request. No prices are sold, no Shopify links, no checkout.
+ *
+ * Zero dependencies. Images are live vendor CDN URLs, used as-is (never upscaled).
+ * node server.js [port] [jsonl] [fieldmap]
+ * port default 0 = OS-assigned free port
+ * jsonl default env VENDOR_JSONL || ../staging/cowtan.jsonl
+ * fieldmap default env VENDOR_FIELDMAP || ./fieldmap.json (optional)
+ */
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+
+const PORT = parseInt(process.argv[2] || '0', 10);
+const DIR = path.join(__dirname, '..', '..', '..', 'staging'); // repo/vendors/<v>/viewer -> repo/staging
+const SRC = process.argv[3] || process.env.VENDOR_JSONL || path.join(__dirname, '..', 'staging', 'cowtan.jsonl');
+const FIELDMAP_PATH = process.argv[4] || process.env.VENDOR_FIELDMAP || path.join(__dirname, 'fieldmap.json');
+const PUBLIC = path.join(__dirname, 'public');
+const DATA = path.join(__dirname, 'data');
+const REQUESTS = path.join(DATA, 'sample-requests.jsonl');
+
+let FIELDMAP = {};
+try { if (fs.existsSync(FIELDMAP_PATH)) FIELDMAP = JSON.parse(fs.readFileSync(FIELDMAP_PATH, 'utf8')); } catch { FIELDMAP = {}; }
+const TITLE = process.env.VENDOR_TITLE || FIELDMAP.title || 'Vendor Trade Lines';
+const SAMPLES_ONLY = FIELDMAP.samples_only !== false; // default true (samples-only / no checkout)
+
+const readJsonl = (f) => (fs.existsSync(f)
+ ? fs.readFileSync(f, 'utf8').trim().split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean)
+ : []);
+
+const num = (v) => { if (v == null) return null; const m = String(v).match(/[\d.]+/); return m ? parseFloat(m[0]) : null; };
+
+// perceived luminance 0..255 from #rrggbb
+function lum(hex) {
+ if (!hex || hex[0] !== '#' || hex.length !== 7) return null;
+ const r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+}
+// hue 0..360 from #rrggbb (for color-wheel sort)
+function hue(hex) {
+ if (!hex || hex[0] !== '#' || hex.length !== 7) return null;
+ let r = parseInt(hex.slice(1, 3), 16) / 255, g = parseInt(hex.slice(3, 5), 16) / 255, b = parseInt(hex.slice(5, 7), 16) / 255;
+ const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
+ if (d === 0) return -1; // grayscale -> sort to end
+ let h;
+ if (mx === r) h = ((g - b) / d) % 6;
+ else if (mx === g) h = (b - r) / d + 2;
+ else h = (r - g) / d + 4;
+ h *= 60; if (h < 0) h += 360;
+ return h;
+}
+
+function buildRows() {
+ const rows = [];
+ let i = 0;
+ for (const r of readJsonl(SRC)) {
+ let images = [];
+ if (Array.isArray(r.all_images)) images = r.all_images;
+ else if (typeof r.all_images === 'string' && r.all_images.trim()) { try { images = JSON.parse(r.all_images); } catch { images = []; } }
+ if (!images.length && r.image_url) images = [r.image_url];
+ const ptype = (r.product_type || '').replace(/^./, (c) => c.toUpperCase()); // normalize casing
+ rows.push({
+ idx: i++,
+ sku: r.dw_sku || r.mfr_sku || String(i),
+ mfr_sku: r.mfr_sku || null,
+ pattern_name: r.pattern_name || r.mfr_sku || '(untitled)',
+ color_name: r.color_name || null,
+ collection: r.collection || null,
+ product_type: ptype || null,
+ material: r.material || null,
+ width: r.width || null,
+ repeat_v: r.repeat_v || null,
+ repeat_h: r.repeat_h || null,
+ color_primary: r.color_primary || null,
+ color_hex: r.color_hex || null,
+ ai_colors: r.ai_colors || [],
+ ai_styles: r.ai_styles || [],
+ ai_patterns: r.ai_patterns || [],
+ repeat_classification: r.repeat_classification || null,
+ finish: r.finish || null,
+ application: r.application || null,
+ coverage: r.coverage || null,
+ fire_rating: r.fire_rating || null,
+ line: r.line || r.material || null,
+ image: r.image_url || images[0] || null,
+ images,
+ image_count: images.length,
+ _lum: lum(r.color_hex),
+ _hue: hue(r.color_hex),
+ });
+ }
+ return rows;
+}
+
+let ROWS = buildRows();
+fs.watchFile(SRC, { interval: 5000 }, () => { ROWS = buildRows(); });
+
+const cmpStr = (a, b) => String(a == null ? '' : a).localeCompare(String(b == null ? '' : b), undefined, { sensitivity: 'base' });
+const nullsLast = (v) => (v == null ? Infinity : v);
+
+const sorters = {
+ newest: null, // natural staged order
+ pattern: (a, b) => cmpStr(a.pattern_name, b.pattern_name),
+ color_name: (a, b) => cmpStr(a.color_name, b.color_name),
+ collection: (a, b) => cmpStr(a.collection, b.collection) || cmpStr(a.pattern_name, b.pattern_name),
+ line: (a, b) => cmpStr(a.line, b.line) || cmpStr(a.pattern_name, b.pattern_name),
+ product_type: (a, b) => cmpStr(a.product_type, b.product_type) || cmpStr(a.pattern_name, b.pattern_name),
+ material: (a, b) => cmpStr(a.material, b.material) || cmpStr(a.pattern_name, b.pattern_name),
+ // light -> dark: brightest first; missing-hex always sort last
+ light: (a, b) => { const la = a._lum == null ? -1 : a._lum, lb = b._lum == null ? -1 : b._lum; return lb - la; },
+ // dark -> light: darkest first; missing-hex always sort last
+ dark: (a, b) => { const la = a._lum == null ? 999 : a._lum, lb = b._lum == null ? 999 : b._lum; return la - lb; },
+ hue: (a, b) => {
+ // grayscale (-1) and null sort to the very end
+ const ha = a._hue == null || a._hue < 0 ? 9999 : a._hue;
+ const hb = b._hue == null || b._hue < 0 ? 9999 : b._hue;
+ return ha - hb;
+ },
+ sku: (a, b) => cmpStr(a.mfr_sku || a.sku, b.mfr_sku || b.sku),
+};
+
+function facets() {
+ const f = { line: {}, product_type: {}, collection: {}, material: {}, color_primary: {}, repeat_classification: {} };
+ for (const r of ROWS) {
+ const bump = (k, v) => { if (v == null || v === '') return; f[k][v] = (f[k][v] || 0) + 1; };
+ bump('line', r.line);
+ bump('product_type', r.product_type);
+ bump('collection', r.collection);
+ bump('material', r.material);
+ bump('color_primary', r.color_primary);
+ bump('repeat_classification', r.repeat_classification);
+ }
+ // sort each facet by count desc, except collection alpha (searchable)
+ const out = {};
+ for (const k of Object.keys(f)) {
+ let entries = Object.entries(f[k]);
+ if (k === 'collection') entries.sort((a, b) => a[0].localeCompare(b[0]));
+ else entries.sort((a, b) => b[1] - a[1]);
+ out[k] = entries.map(([value, count]) => ({ value, count }));
+ }
+ return out;
+}
+
+const json = (res, code, obj) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
+
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://localhost');
+
+ if (u.pathname === '/api/config') {
+ return json(res, 200, { title: TITLE, samples_only: SAMPLES_ONLY, total: ROWS.length });
+ }
+
+ if (u.pathname === '/api/facets') {
+ return json(res, 200, { total: ROWS.length, facets: facets() });
+ }
+
+ if (u.pathname === '/api/skus') {
+ const q = (u.searchParams.get('q') || '').trim().toLowerCase();
+ const sort = u.searchParams.get('sort') || 'newest';
+ const offset = parseInt(u.searchParams.get('offset') || '0', 10);
+ const limit = Math.min(parseInt(u.searchParams.get('limit') || '60', 10), 200);
+ // multi-select facets: pipe-separated; collection can be one of many
+ const sel = (k) => { const v = u.searchParams.get(k); return v ? v.split('|').filter(Boolean) : []; };
+ const fLine = sel('line'), fType = sel('product_type'), fColl = sel('collection');
+ const fMat = sel('material'), fColor = sel('color_primary'), fRep = sel('repeat_classification');
+
+ let rows = ROWS;
+ const inAny = (arr, val) => arr.length === 0 || arr.includes(val == null ? '' : val);
+ rows = rows.filter((r) =>
+ inAny(fLine, r.line) && inAny(fType, r.product_type) && inAny(fColl, r.collection) &&
+ inAny(fMat, r.material) && inAny(fColor, r.color_primary) && inAny(fRep, r.repeat_classification));
+ if (q) {
+ rows = rows.filter((r) => (
+ (r.pattern_name || '') + ' ' + (r.color_name || '') + ' ' + (r.mfr_sku || '') + ' ' +
+ (r.sku || '') + ' ' + (r.collection || '')).toLowerCase().includes(q));
+ }
+ if (sorters[sort]) rows = [...rows].sort(sorters[sort]);
+ const page = rows.slice(offset, offset + limit);
+ return json(res, 200, { total: rows.length, all: ROWS.length, offset, limit, rows: page });
+ }
+
+ if (u.pathname === '/api/sample-request' && req.method === 'POST') {
+ let body = '';
+ req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); });
+ req.on('end', () => {
+ let p; try { p = JSON.parse(body || '{}'); } catch { return json(res, 400, { ok: false, error: 'bad json' }); }
+ const name = (p.name || '').toString().trim();
+ const email = (p.email || '').toString().trim();
+ const items = Array.isArray(p.items) ? p.items.slice(0, 200) : [];
+ if (!name || !email || !/.+@.+\..+/.test(email) || !items.length) {
+ return json(res, 400, { ok: false, error: 'name, valid email, and at least one item are required' });
+ }
+ const rec = {
+ ts: new Date().toISOString(),
+ vendor: TITLE,
+ name, email,
+ company: (p.company || '').toString().trim() || null,
+ phone: (p.phone || '').toString().trim() || null,
+ address: (p.address || '').toString().trim() || null,
+ notes: (p.notes || '').toString().trim() || null,
+ items: items.map((it) => ({
+ sku: (it.sku || '').toString(),
+ mfr_sku: (it.mfr_sku || '').toString() || null,
+ pattern_name: (it.pattern_name || '').toString() || null,
+ color_name: (it.color_name || '').toString() || null,
+ line: (it.line || '').toString() || null,
+ })),
+ ua: (req.headers['user-agent'] || '').slice(0, 200),
+ };
+ try {
+ fs.mkdirSync(DATA, { recursive: true });
+ fs.appendFileSync(REQUESTS, JSON.stringify(rec) + '\n');
+ } catch (e) {
+ return json(res, 500, { ok: false, error: 'could not save request' });
+ }
+ return json(res, 200, { ok: true, count: rec.items.length });
+ });
+ return;
+ }
+
+ // static
+ let pth = u.pathname === '/' ? '/index.html' : u.pathname;
+ const file = path.join(PUBLIC, path.normalize(pth).replace(/^(\.\.[/\\])+/, ''));
+ if (!file.startsWith(PUBLIC) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) { res.writeHead(404); return res.end('not found'); }
+ const ext = path.extname(file);
+ const mime = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.ico': 'image/x-icon' }[ext] || 'text/plain';
+ res.writeHead(200, { 'Content-Type': mime });
+ res.end(fs.readFileSync(file));
+});
+
+server.listen(PORT, () => {
+ const addr = server.address();
+ console.log(`${TITLE} viewer -> http://localhost:${addr.port} (${ROWS.length} products${SAMPLES_ONLY ? ', samples-only' : ''})`);
+});
(oldest)
·
back to Dw Vendor Microsites
·
seed manifest — 118 vendors, 117 buildable, 1 excluded (Nico a8a1464 →