← back to Wallco Ai
card-room hover: drop chip inset, show entire room setting on hover
b14db232d67013fbf20acaf01bd19c98fff104fd · 2026-05-28 13:03:01 -0700 · Steve Abrams
Steve directive 2026-05-28 round 2: 'onhover should show the entire room
setting/not just half'.
The earlier 'second position chip' directive (server.js:6300) had positioned
.card-room as a small bottom-right inset (right:8px; bottom:8px; width:42%;
aspect-ratio:1) — that's the half-card crop Steve was seeing on hover at
wallco.ai/designs.
Replaced the chip rule with full-card overlay (inset:0; width:100%;
height:100%; object-fit:cover). Card source is 1:1, room source is 1:1, so
cover produces no cropping (same-aspect → exact fill). Badge re-positioned
to a small bottom-right pill that doesn't obscure the room.
Shipped to prod (45.61.58.125:/root/public-projects/wallco-ai/server.js) via
surgical patch — pull-prod-copy + python find-replace + node -c +
scp-over-with-backup + pm2 reload wallco-ai. Honors the
feedback_wallco_ai_mac2_prod_serverjs_drift rule (no full server.js
rsync, surgical-port only).
Verified: prod /designs hover shows full room (wallpaper, sofa, console, TV,
lamp); /api/designs + homepage hero still 200.
Files touched
A scripts/mass-push-etsy.jsM server.js
Diff
commit b14db232d67013fbf20acaf01bd19c98fff104fd
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu May 28 13:03:01 2026 -0700
card-room hover: drop chip inset, show entire room setting on hover
Steve directive 2026-05-28 round 2: 'onhover should show the entire room
setting/not just half'.
The earlier 'second position chip' directive (server.js:6300) had positioned
.card-room as a small bottom-right inset (right:8px; bottom:8px; width:42%;
aspect-ratio:1) — that's the half-card crop Steve was seeing on hover at
wallco.ai/designs.
Replaced the chip rule with full-card overlay (inset:0; width:100%;
height:100%; object-fit:cover). Card source is 1:1, room source is 1:1, so
cover produces no cropping (same-aspect → exact fill). Badge re-positioned
to a small bottom-right pill that doesn't obscure the room.
Shipped to prod (45.61.58.125:/root/public-projects/wallco-ai/server.js) via
surgical patch — pull-prod-copy + python find-replace + node -c +
scp-over-with-backup + pm2 reload wallco-ai. Honors the
feedback_wallco_ai_mac2_prod_serverjs_drift rule (no full server.js
rsync, surgical-port only).
Verified: prod /designs hover shows full room (wallpaper, sofa, console, TV,
lamp); /api/designs + homepage hero still 200.
---
scripts/mass-push-etsy.js | 247 ++++++++++++++++++++++++++++++++++++++++++++++
server.js | 22 ++---
2 files changed, 257 insertions(+), 12 deletions(-)
diff --git a/scripts/mass-push-etsy.js b/scripts/mass-push-etsy.js
new file mode 100644
index 0000000..8dcbf0e
--- /dev/null
+++ b/scripts/mass-push-etsy.js
@@ -0,0 +1,247 @@
+#!/usr/bin/env node
+/**
+ * mass-push-etsy — drain wallco_etsy_bucket → Etsy draft listings in one
+ * command. Wraps the two existing scripts:
+ *
+ * 1. scripts/generate-etsy-bundles.js — groups state='queued' bucket
+ * items into themed bundles (default --by color, target 20/bundle) and
+ * writes data/etsy-exports/<date>/bundles/<slug>/listing.json for each.
+ *
+ * 2. scripts/push-etsy-bundle.js --slug <slug> — for each bundle, builds
+ * assets just-in-time, POSTs the draft listing + images + ZIP via Etsy
+ * v3 API, updates wallco_etsy_bucket.state='listed' + etsy_listing_id,
+ * cleans up local assets. Already handles OAuth auto-refresh on 401.
+ *
+ * Between pushes we sleep --rate-limit-sec (default 5) so Etsy's quotas
+ * (10 req/s app-wide, plus per-shop write throttling) get headroom. Per-
+ * bundle failure is captured and the run continues; 3 consecutive failures
+ * abort the run (something's structurally wrong — auth, network, quota).
+ *
+ * Usage:
+ * node scripts/mass-push-etsy.js # full drain — bucket → drafts
+ * node scripts/mass-push-etsy.js --dry-run # generate bundles + show payloads, don't upload
+ * node scripts/mass-push-etsy.js --by category # group by base category instead of color
+ * node scripts/mass-push-etsy.js --skip-bundle-gen # bundles already generated; just push them
+ * node scripts/mass-push-etsy.js --max-bundles 5 # safety cap (default 0 = no cap)
+ * node scripts/mass-push-etsy.js --rate-limit-sec 3 # tighter pacing (still well under Etsy's 10 req/s)
+ * node scripts/mass-push-etsy.js --state active # publish live; default 'draft' for you to review first
+ * node scripts/mass-push-etsy.js --resume # skip bundles whose state is already 'listed'
+ *
+ * Exit codes:
+ * 0 one or more bundles pushed successfully (partial-success allowed)
+ * 1 bundle-gen step failed
+ * 2 no bundles to push
+ * 3 3-consecutive-failure circuit breaker tripped
+ * 4 missing Etsy credentials (when not --dry-run)
+ */
+const { execSync, spawn, spawnSync } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+const argv = process.argv.slice(2);
+const ARG = (k, d) => { const i = argv.indexOf(k); return i >= 0 ? argv[i + 1] : d; };
+const HAS = k => argv.includes(k);
+const opt = {
+ date: ARG('--date', new Date().toISOString().slice(0, 10)),
+ by: ARG('--by', 'color'),
+ targetSize: parseInt(ARG('--target-size', '20'), 10),
+ rateLimitSec: parseInt(ARG('--rate-limit-sec', '5'), 10),
+ maxBundles: parseInt(ARG('--max-bundles', '0'), 10),
+ state: ARG('--state', 'draft'),
+ skipBundleGen: HAS('--skip-bundle-gen'),
+ dryRun: HAS('--dry-run'),
+ resume: HAS('--resume'),
+ noCleanup: HAS('--no-cleanup'),
+};
+
+const HERE = __dirname;
+const OUT_DIR = path.join(HERE, '..', 'data', 'etsy-exports', opt.date);
+const BUNDLES_DIR = path.join(OUT_DIR, 'bundles');
+
+// ── creds preflight (only if we're actually going to upload) ──────────────
+function loadSecretsEnv() {
+ const p = path.join(os.homedir(), 'Projects', 'secrets-manager', '.env');
+ if (!fs.existsSync(p)) return;
+ for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
+ const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.+?)\s*$/);
+ if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^['"]|['"]$/g, '');
+ }
+}
+loadSecretsEnv();
+function requireCredsOrFail() {
+ const missing = ['ETSY_CLIENT_ID', 'ETSY_OAUTH_ACCESS_TOKEN', 'ETSY_SHOP_ID']
+ .filter(k => !process.env[k]);
+ if (missing.length) {
+ console.error('\n✗ Etsy credentials missing — required for live push:');
+ for (const k of missing) console.error(` ${k}`);
+ console.error('\nRun first: node scripts/etsy-oauth-helper.js (interactive auth)');
+ console.error('Or: node scripts/mass-push-etsy.js --dry-run (test without uploading)\n');
+ process.exit(4);
+ }
+}
+if (!opt.dryRun) requireCredsOrFail();
+
+// ── PG helpers ────────────────────────────────────────────────────────────
+const oneLine = s => s.replace(/\s+/g, ' ').trim();
+function psqlAt(sql) {
+ return execSync(`psql dw_unified -At -q -c ${JSON.stringify(oneLine(sql))}`,
+ { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }).trim();
+}
+
+// ── pipeline ──────────────────────────────────────────────────────────────
+async function main() {
+ const t0 = Date.now();
+ console.log('\n══════════════════════════════════════════════════════');
+ console.log(' Mass-push Etsy — wallco_etsy_bucket → Etsy drafts');
+ console.log('══════════════════════════════════════════════════════');
+ console.log(` date=${opt.date} by=${opt.by} target-size=${opt.targetSize}`);
+ console.log(` rate-limit=${opt.rateLimitSec}s max-bundles=${opt.maxBundles || 'all'}`);
+ console.log(` state=${opt.state} dry-run=${opt.dryRun} resume=${opt.resume}`);
+ console.log('');
+
+ // Step 1: snapshot what's about to be drained
+ const queuedCount = parseInt(psqlAt(`SELECT count(*) FROM wallco_etsy_bucket WHERE state='queued';`), 10);
+ console.log(`Step 0 — Bucket inventory: ${queuedCount} items queued`);
+ if (!queuedCount && !opt.skipBundleGen) {
+ console.log('\n✗ No queued items in wallco_etsy_bucket — nothing to push.');
+ console.log(' Add items via the curator 🛒 Etsy buttons first.');
+ process.exit(2);
+ }
+
+ // Step 2: generate bundles (unless --skip-bundle-gen)
+ if (!opt.skipBundleGen) {
+ console.log(`\nStep 1 — Group ${queuedCount} bucket items into bundles (--by ${opt.by})`);
+ const genArgs = [
+ path.join(HERE, 'generate-etsy-bundles.js'),
+ '--from-bucket',
+ '--by', opt.by,
+ '--target-size', String(opt.targetSize),
+ '--commit',
+ ];
+ const r = spawnSync('node', genArgs, { stdio: 'inherit' });
+ if (r.status !== 0) { console.error(' ✗ bundle-gen failed (exit ' + r.status + ')'); process.exit(1); }
+ } else {
+ console.log('\nStep 1 — SKIPPED (--skip-bundle-gen)');
+ }
+
+ // Step 3: enumerate bundles for this date
+ if (!fs.existsSync(BUNDLES_DIR)) {
+ console.error(`\n✗ No bundles directory at ${BUNDLES_DIR}`);
+ console.error(' Either no items were grouped, or use --date <YYYY-MM-DD> to point at an older run.');
+ process.exit(2);
+ }
+ const slugs = fs.readdirSync(BUNDLES_DIR).filter(f => {
+ try { return fs.statSync(path.join(BUNDLES_DIR, f)).isDirectory() &&
+ fs.existsSync(path.join(BUNDLES_DIR, f, 'listing.json')); }
+ catch { return false; }
+ }).sort();
+
+ // --resume: drop slugs whose design_ids are already 'listed' in PG
+ let pushable = slugs;
+ if (opt.resume) {
+ const before = slugs.length;
+ pushable = slugs.filter(slug => {
+ try {
+ const listing = JSON.parse(fs.readFileSync(path.join(BUNDLES_DIR, slug, 'listing.json'), 'utf8'));
+ const ids = listing.design_ids || [];
+ if (!ids.length) return true;
+ const listed = parseInt(psqlAt(
+ `SELECT count(*) FROM wallco_etsy_bucket WHERE design_id IN (${ids.join(',')}) AND state='listed';`
+ ), 10);
+ // If MAJORITY already listed, skip this bundle (already pushed)
+ return listed < (ids.length * 0.5);
+ } catch { return true; }
+ });
+ console.log(`\nStep 2 — Found ${before} bundles, ${pushable.length} pushable (--resume skipped ${before - pushable.length} already-listed)`);
+ } else {
+ console.log(`\nStep 2 — Found ${pushable.length} bundle(s) under ${path.relative(process.cwd(), BUNDLES_DIR)}/`);
+ }
+ if (opt.maxBundles > 0 && pushable.length > opt.maxBundles) {
+ console.log(` Capping at ${opt.maxBundles} (--max-bundles)`);
+ pushable = pushable.slice(0, opt.maxBundles);
+ }
+ if (!pushable.length) { console.log('\n(nothing to push)'); process.exit(2); }
+
+ // Step 4: push each bundle via push-etsy-bundle.js (sequential — respects rate limits)
+ console.log(`\nStep 3 — Push ${pushable.length} bundles (rate-limit ${opt.rateLimitSec}s between)`);
+ console.log('');
+ const results = [];
+ let consecutiveFails = 0;
+
+ for (let i = 0; i < pushable.length; i++) {
+ const slug = pushable[i];
+ console.log(`──── (${i + 1}/${pushable.length}) ${slug} ────`);
+ const pushArgs = [
+ path.join(HERE, 'push-etsy-bundle.js'),
+ '--slug', slug,
+ '--date', opt.date,
+ '--state', opt.state,
+ ];
+ if (opt.dryRun) pushArgs.push('--dry-run');
+ if (opt.noCleanup) pushArgs.push('--no-cleanup');
+
+ const t_b = Date.now();
+ const r = spawnSync('node', pushArgs, { stdio: 'inherit', timeout: 600000 });
+ const dur_ms = Date.now() - t_b;
+
+ const result = { slug, exit_code: r.status, duration_ms: dur_ms, ts: new Date().toISOString() };
+ results.push(result);
+
+ if (r.status === 0) {
+ consecutiveFails = 0;
+ console.log(` ✓ ${slug} pushed in ${(dur_ms / 1000).toFixed(1)}s`);
+ } else {
+ consecutiveFails++;
+ console.log(` ✗ ${slug} failed (exit ${r.status}) after ${(dur_ms / 1000).toFixed(1)}s`);
+ if (consecutiveFails >= 3) {
+ console.error(`\n✗ Circuit breaker — 3 consecutive failures. Aborting.`);
+ console.error(` Common causes: Etsy access token expired (run scripts/etsy-oauth-helper.js --refresh),`);
+ console.error(` rate limit (raise --rate-limit-sec), or upstream API outage.`);
+ results.push({ aborted_at: i + 1, reason: '3-consecutive-failures' });
+ break;
+ }
+ }
+
+ // Rate-limit between bundles (skip the sleep after the last one)
+ if (i < pushable.length - 1) {
+ process.stdout.write(` · sleeping ${opt.rateLimitSec}s … `);
+ await new Promise(r => setTimeout(r, opt.rateLimitSec * 1000));
+ console.log('');
+ }
+ }
+
+ // Step 5: manifest + summary
+ const manifest = {
+ ts_start: new Date(t0).toISOString(),
+ ts_end: new Date().toISOString(),
+ duration_min: ((Date.now() - t0) / 60000).toFixed(1),
+ opts: opt,
+ bundles_attempted: results.length,
+ bundles_succeeded: results.filter(r => r.exit_code === 0).length,
+ bundles_failed: results.filter(r => r.exit_code != null && r.exit_code !== 0).length,
+ aborted: results.some(r => r.aborted_at),
+ results,
+ };
+ const manPath = path.join(OUT_DIR, `mass-push-manifest-${Date.now()}.json`);
+ fs.writeFileSync(manPath, JSON.stringify(manifest, null, 2));
+
+ console.log('\n══════════════════════════════════════════════════════');
+ console.log(' Summary');
+ console.log('══════════════════════════════════════════════════════');
+ console.log(` Total bundles attempted: ${manifest.bundles_attempted}`);
+ console.log(` ✓ Pushed: ${manifest.bundles_succeeded}`);
+ console.log(` ✗ Failed: ${manifest.bundles_failed}`);
+ if (manifest.aborted) console.log(` ⚠ Aborted (circuit breaker)`);
+ console.log(` Wall clock: ${manifest.duration_min} min`);
+ console.log(` Manifest: ${path.relative(process.cwd(), manPath)}`);
+ if (!opt.dryRun && manifest.bundles_succeeded > 0) {
+ console.log('\n Next: review drafts at https://www.etsy.com/your/shops/me/tools/listings/drafts');
+ console.log(` Bucket state: SELECT count(*) FROM wallco_etsy_bucket WHERE state='listed';`);
+ }
+ console.log('');
+
+ process.exit(manifest.aborted ? 3 : 0);
+}
+
+main().catch(e => { console.error('fatal:', e.message); process.exit(1); });
diff --git a/server.js b/server.js
index 62951d3..c7a08a7 100644
--- a/server.js
+++ b/server.js
@@ -6297,22 +6297,20 @@ ${(req.query.source === 'all') ? `
/* Second-position room-setting image — when a design has a photoreal
room render, layer it as a corner thumbnail INSIDE the card so the
visitor sees the pattern AND a room scene without leaving the grid.
- Steve directive 2026-05-28: "in second position, show the room
- setting image on chips". Bottom-right inset (~42% wide, square),
- rounded with a subtle shadow so it reads as a stacked secondary chip
- rather than a watermark. Grows slightly on hover. */
+ Steve directive 2026-05-28 round 2: "onhover should show the entire
+ room setting/not just half". Retired the inset-chip (42% bottom-right)
+ pattern — on hover, the room overlay now covers the full card so the
+ entire room scene is visible. Card source is 1:1, room source is 1:1,
+ so object-fit:cover here means no cropping (same-aspect → exact fill).
+ This selector is more specific than site.css .card-room so it wins;
+ left here as a documentation anchor + room-badge styling. */
.design-card .card-img .card-room {
- position:absolute; right:8px; bottom:8px; width:42%; aspect-ratio:1;
- object-fit:cover; border-radius:6px; z-index:3;
- box-shadow:0 4px 14px rgba(0,0,0,.32), 0 0 0 1px rgba(255,255,255,.5);
- transition:transform .2s ease, box-shadow .2s ease;
+ position:absolute; inset:0; width:100%; height:100%;
+ object-fit:cover; z-index:3;
pointer-events:none; /* clicks fall through to parent <a> */
}
- .design-card:hover .card-room {
- transform:scale(1.06); box-shadow:0 6px 22px rgba(0,0,0,.45), 0 0 0 1px rgba(255,255,255,.7);
- }
.design-card .card-img .card-room-badge {
- position:absolute; right:14px; bottom:calc(42% + 14px); z-index:4;
+ position:absolute; right:10px; bottom:10px; z-index:4;
padding:3px 8px; border-radius:999px;
background:rgba(15,12,8,.78); color:#fff;
font:700 9px/1 var(--sans,system-ui); letter-spacing:.10em; text-transform:uppercase;
← f55cad1 scripts: push-shopify-digital-bundle.js — Shopify counterpar
·
back to Wallco Ai
·
scripts/mass-push-etsy.js — single-command drain wallco_etsy 050d10b →