[object Object]

← back to Wallco Ai

Shopify: bump all wallco/SP scripts to API 2026-01 · add read_orders scope preflight in fetch_recent_orders_palette.js with admin-link-out helper · admin/_devlogin restored · inspiration→generate flow working (3 designs in ~90s, vendor-anonymous prompts)

6debe836225fce45ab96caccd64e9145746d7328 · 2026-05-11 18:51:00 -0700 · Steve

Files touched

Diff

commit 6debe836225fce45ab96caccd64e9145746d7328
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon May 11 18:51:00 2026 -0700

    Shopify: bump all wallco/SP scripts to API 2026-01 · add read_orders scope preflight in fetch_recent_orders_palette.js with admin-link-out helper · admin/_devlogin restored · inspiration→generate flow working (3 designs in ~90s, vendor-anonymous prompts)
---
 data/chip-chats.json                   |  28 +----
 data/designs.json                      | 195 +++++++++++++++++++++++++++++++++
 data/moodboards.json                   |  12 +-
 data/pairings.json                     |  83 ++------------
 data/reviews.json                      |  13 ++-
 data/vision-cache.json                 |   3 +-
 public/js/admin.js                     |  40 ++++++-
 scripts/fetch_recent_orders_palette.js |  41 ++++++-
 server.js                              |  34 ++++++
 src/admin.js                           |  87 +++++++++++++--
 10 files changed, 410 insertions(+), 126 deletions(-)

diff --git a/data/chip-chats.json b/data/chip-chats.json
index 98964c0..eec8b78 100644
--- a/data/chip-chats.json
+++ b/data/chip-chats.json
@@ -1,36 +1,14 @@
 {
-  "11:cat-floral": [
-    {
-      "role": "user",
-      "content": "Why is this floral chip relevant to my collection?",
-      "ts": "2026-05-12T00:15:21.709Z"
-    },
-    {
-      "role": "assistant",
-      "content": "The \"floral\" chip is relevant as the design features soft, organic floral motifs in a warm, earthy palette (#9c9172), evoking natural textures and botanical elegance. The seamless tile format ensures the floral pattern flows continuously, ideal for creating immersive, nature-inspired wallcoverings. To enhance the chip's impact, consider amplifying the contrast between floral elements and background tones or adjusting scale for bolder visual presence.",
-      "ts": "2026-05-12T00:15:21.709Z"
-    }
-  ],
   "13:cat-floral": [
     {
       "role": "user",
       "content": "What scale of room does this work in?",
-      "ts": "2026-05-12T00:58:57.673Z"
-    },
-    {
-      "role": "assistant",
-      "content": "\"Honey Garden No.13\" works well in medium to large spaces due to its rich, earthy tone (#87774c) and bold floral motifs, which add visual depth without overwhelming smaller areas. For smaller rooms, consider reducing the pattern scale or lightening the dominant color to maintain balance.",
-      "ts": "2026-05-12T00:58:57.673Z"
-    },
-    {
-      "role": "user",
-      "content": "What scale of room does this work in?",
-      "ts": "2026-05-12T01:01:08.389Z"
+      "ts": "2026-05-12T01:38:04.429Z"
     },
     {
       "role": "assistant",
-      "content": "\"Honey Garden No.13\" works best in medium to large rooms, where its bold floral motifs and deep earthy tone (#87774c) create a rich, immersive atmosphere. In smaller spaces, reduce the pattern scale or use a lighter variant of the dominant color to avoid visual overcrowding.",
-      "ts": "2026-05-12T01:01:08.389Z"
+      "content": "\"Honey Garden No.13\" works well in medium to large rooms due to its floral scale—bold, stylized blooms in #87774c (warm terracotta) add visual depth without overwhelming space. For smaller rooms, consider reducing floral size or lightening the palette to avoid claustrophobia.",
+      "ts": "2026-05-12T01:38:04.430Z"
     }
   ]
 }
\ No newline at end of file
diff --git a/data/designs.json b/data/designs.json
index fac2dd9..cc108ed 100644
--- a/data/designs.json
+++ b/data/designs.json
@@ -463,5 +463,200 @@
     "seed": 536522826,
     "is_published": false,
     "created_at": "2026-05-11T23:56:17.861881+00:00"
+  },
+  {
+    "id": 42,
+    "kind": "seamless_tile",
+    "category": "damask",
+    "dominant_hex": "#162f36",
+    "saturation": 0.421,
+    "title": "Marine Heirloom No.42",
+    "handle": "wallco-0042",
+    "image_url": "/designs/img/1778546385937_218100061.png",
+    "filename": "1778546385937_218100061.png",
+    "generator": "Replicate SDXL",
+    "seed": 218100061,
+    "is_published": false,
+    "created_at": "2026-05-12T00:39:53.816852+00:00"
+  },
+  {
+    "id": 43,
+    "kind": "seamless_tile",
+    "category": "floral",
+    "dominant_hex": "#683621",
+    "saturation": 0.518,
+    "title": "Amber Garden No.43",
+    "handle": "wallco-0043",
+    "image_url": "/designs/img/1778546451102_1288400340.png",
+    "filename": "1778546451102_1288400340.png",
+    "generator": "Replicate SDXL",
+    "seed": 1288400340,
+    "is_published": false,
+    "created_at": "2026-05-12T00:40:51.578874+00:00"
+  },
+  {
+    "id": 44,
+    "kind": "seamless_tile",
+    "category": "arts and crafts",
+    "dominant_hex": "#11242c",
+    "saturation": 0.443,
+    "title": "Sapphire Reverie No.44",
+    "handle": "wallco-0044",
+    "image_url": "/designs/img/1778547697906_1698245296.png",
+    "filename": "1778547697906_1698245296.png",
+    "generator": "Replicate SDXL",
+    "seed": 1698245296,
+    "is_published": false,
+    "created_at": "2026-05-12T01:01:45.757758+00:00"
+  },
+  {
+    "id": 45,
+    "kind": "seamless_tile",
+    "category": "damask",
+    "dominant_hex": "#465541",
+    "saturation": 0.133,
+    "title": "Sage Baroque No.45",
+    "handle": "wallco-0045",
+    "image_url": "/designs/img/1778548235154_224020684.png",
+    "filename": "1778548235154_224020684.png",
+    "generator": "Replicate SDXL",
+    "seed": 224020684,
+    "is_published": false,
+    "created_at": "2026-05-12T01:13:12.328157+00:00"
+  },
+  {
+    "id": 46,
+    "kind": "seamless_tile",
+    "category": "damask",
+    "dominant_hex": "#2e403d",
+    "saturation": 0.164,
+    "title": "Marine Damask No.46",
+    "handle": "wallco-0046",
+    "image_url": "/designs/img/1778550250324_1309443231.png",
+    "filename": "1778550250324_1309443231.png",
+    "generator": "Replicate SDXL",
+    "seed": 1309443231,
+    "is_published": false,
+    "created_at": "2026-05-12T01:44:21.390474+00:00"
+  },
+  {
+    "id": 47,
+    "kind": "seamless_tile",
+    "category": "damask",
+    "dominant_hex": "#eee6d3",
+    "saturation": 0.443,
+    "title": "Honey Heirloom No.47",
+    "handle": "wallco-0047",
+    "image_url": "/designs/img/1778550261052_125177861.png",
+    "filename": "1778550261052_125177861.png",
+    "generator": "Replicate SDXL",
+    "seed": 125177861,
+    "is_published": false,
+    "created_at": "2026-05-12T01:44:29.012184+00:00"
+  },
+  {
+    "id": 48,
+    "kind": "seamless_tile",
+    "category": "damask",
+    "dominant_hex": "#2b3535",
+    "saturation": 0.104,
+    "title": "Marine Atelier No.48",
+    "handle": "wallco-0048",
+    "image_url": "/designs/img/1778550269483_2146043558.png",
+    "filename": "1778550269483_2146043558.png",
+    "generator": "Replicate SDXL",
+    "seed": 2146043558,
+    "is_published": false,
+    "created_at": "2026-05-12T01:44:37.051318+00:00"
+  },
+  {
+    "id": 49,
+    "kind": "seamless_tile",
+    "category": "damask",
+    "dominant_hex": "#425256",
+    "saturation": 0.132,
+    "title": "Marine Salon No.49",
+    "handle": "wallco-0049",
+    "image_url": "/designs/img/1778550277437_1181433269.png",
+    "filename": "1778550277437_1181433269.png",
+    "generator": "Replicate SDXL",
+    "seed": 1181433269,
+    "is_published": false,
+    "created_at": "2026-05-12T01:44:45.205345+00:00"
+  },
+  {
+    "id": 50,
+    "kind": "seamless_tile",
+    "category": "damask",
+    "dominant_hex": "#0c0a0a",
+    "saturation": 0.091,
+    "title": "Rose Baroque No.50",
+    "handle": "wallco-0050",
+    "image_url": "/designs/img/1778550285656_1438445289.png",
+    "filename": "1778550285656_1438445289.png",
+    "generator": "Replicate SDXL",
+    "seed": 1438445289,
+    "is_published": false,
+    "created_at": "2026-05-12T01:45:02.91115+00:00"
+  },
+  {
+    "id": 51,
+    "kind": "seamless_tile",
+    "category": "mixed",
+    "dominant_hex": "#a17f5d",
+    "saturation": 0.268,
+    "title": "Amber Studio No.51",
+    "handle": "wallco-0051",
+    "image_url": "/designs/img/1778550287606_1205386950.png",
+    "filename": "1778550287606_1205386950.png",
+    "generator": "Replicate SDXL",
+    "seed": 1205386950,
+    "is_published": false,
+    "created_at": "2026-05-12T01:45:11.421649+00:00"
+  },
+  {
+    "id": 52,
+    "kind": "seamless_tile",
+    "category": "mixed",
+    "dominant_hex": "#9e8ab3",
+    "saturation": 0.212,
+    "title": "Mauve Origin No.52",
+    "handle": "wallco-0052",
+    "image_url": "/designs/img/1778550319969_275538065.png",
+    "filename": "1778550319969_275538065.png",
+    "generator": "Replicate SDXL",
+    "seed": 275538065,
+    "is_published": false,
+    "created_at": "2026-05-12T01:45:45.427434+00:00"
+  },
+  {
+    "id": 53,
+    "kind": "seamless_tile",
+    "category": "mixed",
+    "dominant_hex": "#7e8897",
+    "saturation": 0.107,
+    "title": "Sapphire Folio No.53",
+    "handle": "wallco-0053",
+    "image_url": "/designs/img/1778550352534_1025904412.png",
+    "filename": "1778550352534_1025904412.png",
+    "generator": "Replicate SDXL",
+    "seed": 1025904412,
+    "is_published": false,
+    "created_at": "2026-05-12T01:46:02.437832+00:00"
+  },
+  {
+    "id": 54,
+    "kind": "seamless_tile",
+    "category": "damask",
+    "dominant_hex": "#dfc492",
+    "saturation": 0.546,
+    "title": "Honey Salon No.54",
+    "handle": "wallco-0054",
+    "image_url": "/designs/img/1778550372683_1513008276.png",
+    "filename": "1778550372683_1513008276.png",
+    "generator": "Replicate SDXL",
+    "seed": 1513008276,
+    "is_published": false,
+    "created_at": "2026-05-12T01:46:22.491814+00:00"
   }
 ]
\ No newline at end of file
diff --git a/data/moodboards.json b/data/moodboards.json
index e48cd92..872b901 100644
--- a/data/moodboards.json
+++ b/data/moodboards.json
@@ -1,14 +1,4 @@
 {
-  "11": [
-    {
-      "pin_key": "c:#5d73b1",
-      "type": "color",
-      "hex": "#5d73b1",
-      "label": "test",
-      "reason": "test",
-      "pinned_at": "2026-05-12T00:43:50.526Z"
-    }
-  ],
   "14": [
     {
       "pin_key": "d:12",
@@ -20,7 +10,7 @@
       "hex": "#a09279",
       "idx": 0,
       "pinned": false,
-      "pinned_at": "2026-05-12T01:03:49.316Z"
+      "pinned_at": "2026-05-12T01:39:15.311Z"
     }
   ]
 }
\ No newline at end of file
diff --git a/data/pairings.json b/data/pairings.json
index 07bfee2..9c98444 100644
--- a/data/pairings.json
+++ b/data/pairings.json
@@ -1,75 +1,8 @@
 {
-  "11": {
-    "design_id": 11,
-    "vision_description": "Floral pattern with pink peonies on a light background; large scale repeat; formal, elegant, and luxurious mood.",
-    "generated_at": "2026-05-12T00:47:19.449Z",
-    "suggestions": [
-      {
-        "type": "design",
-        "design_id": 24,
-        "label": "Sapphire Floret No.24",
-        "reason": "Same family (floral), different palette — pattern echo with chromatic counterpoint",
-        "thumb_url": "/designs/img/1778543153319_465538454.png",
-        "hex": "#0b1730",
-        "idx": 0,
-        "pin_key": "d:24",
-        "pinned": false
-      },
-      {
-        "type": "design",
-        "design_id": 36,
-        "label": "Honey Studio No.36",
-        "reason": "Different language (mixed), shared palette — connector for a layered room",
-        "thumb_url": "/designs/img/1778543274946_268939687.png",
-        "hex": "#8f8b61",
-        "idx": 1,
-        "pin_key": "d:36",
-        "pinned": false
-      },
-      {
-        "type": "color",
-        "hex": "#5d73b1",
-        "label": "Complement · #5d73b1",
-        "reason": "Direct chromatic opposite — trim, hardware, or a single accent piece",
-        "idx": 2,
-        "pin_key": "c:#5d73b1",
-        "pinned": true
-      },
-      {
-        "type": "color",
-        "hex": "#9db15d",
-        "label": "Analogous · #9db15d",
-        "reason": "Neighboring hue — supporting fabric or upholstery, harmonious not loud",
-        "idx": 3,
-        "pin_key": "c:#9db15d",
-        "pinned": false
-      },
-      {
-        "type": "material",
-        "label": "Aged brass hardware",
-        "reason": "Warm metallics echo pink peonies and add formal luxury without overpowering the delicate floral motif.",
-        "icon": "✦",
-        "vision_grounded": true,
-        "idx": 4,
-        "pin_key": "m:aged-brass-hardware",
-        "pinned": false
-      },
-      {
-        "type": "material",
-        "label": "Walnut wood paneling",
-        "reason": "Rich, textured wood contrasts with light background, enhancing depth and sophistication of the elegant floral design.",
-        "icon": "✦",
-        "vision_grounded": true,
-        "idx": 5,
-        "pin_key": "m:walnut-wood-paneling",
-        "pinned": false
-      }
-    ]
-  },
   "14": {
     "design_id": 14,
-    "vision_description": "Floral pattern with yellow flowers on a dark background; small-scale repeat; formal mood.",
-    "generated_at": "2026-05-12T00:59:35.410Z",
+    "vision_description": "Floral pattern with yellow flowers on a dark background; medium scale repeat; formal mood.",
+    "generated_at": "2026-05-12T01:39:04.080Z",
     "suggestions": [
       {
         "type": "design",
@@ -113,22 +46,22 @@
       },
       {
         "type": "material",
-        "label": "Gold leaf trim",
-        "reason": "Metallic sheen contrasts with dark background, amplifying yellow flowers' vibrancy.",
+        "label": "Gold leaf accent wall",
+        "reason": "Amplifies yellow flowers' warmth; contrasts with dark base for formal elegance.",
         "icon": "✦",
         "vision_grounded": true,
         "idx": 4,
-        "pin_key": "m:gold-leaf-trim",
+        "pin_key": "m:gold-leaf-accent-wall",
         "pinned": false
       },
       {
         "type": "material",
-        "label": "Walnut wood paneling",
-        "reason": "Rich, dark wood complements formal tone and deep hue, adding warmth and sophistication.",
+        "label": "Matte black trim molding",
+        "reason": "Sharpens floral focus against deep background; adds structured sophistication.",
         "icon": "✦",
         "vision_grounded": true,
         "idx": 5,
-        "pin_key": "m:walnut-wood-paneling",
+        "pin_key": "m:matte-black-trim-molding",
         "pinned": false
       }
     ]
diff --git a/data/reviews.json b/data/reviews.json
index b81fd4a..3815ead 100644
--- a/data/reviews.json
+++ b/data/reviews.json
@@ -5,7 +5,16 @@
     "color": 5,
     "style": 5,
     "decision": "keep",
-    "why": "The design is strong but lacks vibrancy and modern flair in color and style, warranting refinement before final use.",
-    "updated_at": "2026-05-12T01:04:49.816Z"
+    "why": "The muted, earthy color palette lacks vibrancy and contrast, diminishing the floral design's visual impact despite its structured compositio",
+    "updated_at": "2026-05-12T01:36:34.661Z"
+  },
+  "12": {
+    "id": "12",
+    "design": 5,
+    "color": 5,
+    "style": 5,
+    "decision": "reject",
+    "why": "The design lacks visual vibrancy and distinctiveness, resulting in a flat, unengaging pattern that fails to capture attention or stand out i",
+    "updated_at": "2026-05-12T01:37:16.898Z"
   }
 }
\ No newline at end of file
diff --git a/data/vision-cache.json b/data/vision-cache.json
index f5e4536..e28a026 100644
--- a/data/vision-cache.json
+++ b/data/vision-cache.json
@@ -1,4 +1,3 @@
 {
-  "11": "Floral pattern with pink peonies on a light background; large scale repeat; formal, elegant, and luxurious mood.",
-  "14": "Floral pattern with yellow flowers on a dark background; small-scale repeat; formal mood."
+  "14": "Floral pattern with yellow flowers on a dark background; medium scale repeat; formal mood."
 }
\ No newline at end of file
diff --git a/public/js/admin.js b/public/js/admin.js
index 1489b84..782efe1 100644
--- a/public/js/admin.js
+++ b/public/js/admin.js
@@ -48,7 +48,43 @@
     });
   });
 
-  // Generate-from-pool button (stub for now — wired to next-tick endpoint)
+  // Generate-from-pool button
   const gen = document.getElementById('gen-btn');
-  if (gen) gen.addEventListener('click', () => alert('Generate-from-pool — wiring in next tick. Open chat panel meanwhile.'));
+  if (gen) gen.addEventListener('click', async () => {
+    gen.disabled = true;
+    const orig = gen.textContent;
+    gen.textContent = 'Generating 3 designs… (~90s)';
+    try {
+      const r = await fetch('/api/admin/inspiration/generate', {
+        method: 'POST', headers: { 'Content-Type': 'application/json' },
+        credentials: 'include'
+      });
+      const j = await r.json();
+      if (!r.ok) {
+        alert('Generation failed: ' + (j.error || r.status));
+        gen.disabled = false; gen.textContent = orig; return;
+      }
+      const designs = j.designs || [];
+      // Render results above grid
+      const out = document.createElement('div');
+      out.style.cssText = 'margin:20px 0;padding:18px;background:var(--card-bg);border:1px solid var(--gold);border-radius:8px';
+      out.innerHTML = `<h2 style="font:300 22px/1 var(--serif);margin:0 0 10px;color:var(--ink)">${designs.length} new wallco originals from your pool</h2>
+        <div class="p-grid" style="margin-top:10px">
+        ${designs.filter(d => !d.error).map(d => `
+          <a href="/design/${d.id}" class="p-card" style="text-decoration:none;color:inherit">
+            <div class="p-img" style="background-image:url('${d.image_url}')"></div>
+            <div class="p-meta">
+              <div class="p-name">Design #${d.id}</div>
+              <div class="p-coll"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${d.dominant_hex};vertical-align:middle;margin-right:4px"></span>${d.dominant_hex}</div>
+            </div>
+          </a>`).join('')}
+        </div>`;
+      gen.after(out);
+      gen.textContent = `✓ Generated ${designs.length} · click to generate 3 more`;
+      gen.disabled = false;
+    } catch (e) {
+      alert('Generation error: ' + e.message);
+      gen.disabled = false; gen.textContent = orig;
+    }
+  });
 })();
diff --git a/scripts/fetch_recent_orders_palette.js b/scripts/fetch_recent_orders_palette.js
index c2b39f1..308b6cf 100644
--- a/scripts/fetch_recent_orders_palette.js
+++ b/scripts/fetch_recent_orders_palette.js
@@ -52,13 +52,49 @@ function sqlStr(v) {
 }
 
 async function shopifyJSON(path) {
-  const r = await fetch(`${SHOPIFY_BASE}/admin/api/2024-04${path}`, {
+  const r = await fetch(`${SHOPIFY_BASE}/admin/api/2026-01${path}`, {
     headers: { 'X-Shopify-Access-Token': ADMIN, 'Accept': 'application/json' },
   });
-  if (!r.ok) throw new Error(`Shopify ${path} → ${r.status} ${(await r.text()).slice(0, 200)}`);
+  if (!r.ok) {
+    const body = (await r.text()).slice(0, 400);
+    if (r.status === 403 || /requires merchant approval|access_scope|not approved/i.test(body)) {
+      console.error('\n========================================================================');
+      console.error(' Shopify 403 — your custom app is missing the required scope.');
+      console.error('========================================================================');
+      console.error(' This script needs:  read_orders');
+      console.error(` Token currently has only: read_products / read_inventory / read_publications`);
+      console.error('');
+      console.error(' FIX (one-time, ~30 seconds):');
+      console.error(`   1. Open https://${SHOPIFY_BASE.replace(/^https?:\/\//,'')}/admin/settings/apps/development`);
+      console.error('   2. Click your custom app → Configuration → Admin API access scopes');
+      console.error('   3. Check  read_orders  → Save → Reinstall the app');
+      console.error('   4. The SHOPIFY_ADMIN_TOKEN env var stays the same — Shopify keeps it.');
+      console.error('========================================================================\n');
+    }
+    throw new Error(`Shopify ${path} → ${r.status} ${body}`);
+  }
   return r.json();
 }
 
+async function preflightScopes() {
+  try {
+    const r = await fetch(`${SHOPIFY_BASE}/admin/oauth/access_scopes.json`, {
+      headers: { 'X-Shopify-Access-Token': ADMIN, 'Accept': 'application/json' }
+    });
+    if (!r.ok) return; // can't pre-check; let the real call surface the error
+    const j = await r.json();
+    const have = (j.access_scopes || []).map(s => s.handle);
+    if (!have.includes('read_orders')) {
+      const msg = `[preflight] Token is missing read_orders scope (have: ${have.join(', ')}).\nAdd it at: https://${SHOPIFY_BASE.replace(/^https?:\/\//,'')}/admin/settings/apps/development\nExpected scope: read_orders`;
+      throw new Error(msg);
+    }
+    console.log('[preflight] Token has read_orders ✓');
+  } catch (e) {
+    if (/read_orders scope/.test(e.message)) throw e;
+    // ignore other preflight failures
+  }
+}
+
 async function getImageDominantHex(imageUrl) {
   // Download to /tmp + run sips to extract dominant color
   const fname = path.join(TMP, path.basename(imageUrl.split('?')[0]));
@@ -90,6 +126,7 @@ async function main() {
   const argv = process.argv.slice(2);
   const limit = parseInt(argv[argv.indexOf('--limit') + 1] || '50', 10);
 
+  await preflightScopes();
   console.log(`Fetching last ${limit} orders from ${SHOPIFY_BASE}…`);
   const orders = (await shopifyJSON(`/orders.json?status=any&limit=${Math.min(limit, 250)}`)).orders || [];
   console.log(`Got ${orders.length} orders`);
diff --git a/server.js b/server.js
index a4e8b84..1f3c926 100644
--- a/server.js
+++ b/server.js
@@ -3152,6 +3152,40 @@ app.get('/health', (_req, res) => {
   res.json({ ok: true, site: SITE, count: DESIGNS.length, port: PORT });
 });
 
+// ── DEV LOGIN — sets dw_auth HS256 cookie for admin layer + inline editor (dev mode only)
+const __crypto = require('crypto');
+function __b64u(s) { return Buffer.from(s).toString('base64').replace(/=+$/,'').replace(/\+/g,'-').replace(/\//g,'_'); }
+function __sign(secret, payload) {
+  const h = __b64u(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
+  const p = __b64u(JSON.stringify(payload));
+  const sig = __b64u(__crypto.createHmac('sha256', secret).update(h + '.' + p).digest());
+  return `${h}.${p}.${sig}`;
+}
+app.get('/_devlogin', (req, res) => {
+  if ((process.env.AUTH_MODE || 'dev') !== 'dev') return res.status(403).send('dev login disabled in prod');
+  const secret = process.env.AUTH_DEV_SECRET || 'wallco-dev-secret-change-for-prod';
+  const exp = Math.floor(Date.now() / 1000) + 8 * 3600;
+  const token = __sign(secret, {
+    email: 'steve@designerwallcoverings.com',
+    role: 'admin',
+    iss: 'https://auth.agentabrams.com',
+    exp
+  });
+  res.cookie('dw_auth', token, { httpOnly: false, maxAge: 8 * 3600 * 1000, sameSite: 'lax' });
+  const next = req.query.next || '/admin';
+  res.type('text/html').send(`<!doctype html><meta charset="utf-8"><title>Admin login</title>
+<style>body{font:14px/1.5 -apple-system,system-ui,sans-serif;background:#0f0e0c;color:#e8e2d6;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}.card{background:#1a1816;padding:36px 44px;border-radius:10px;border:1px solid #2a2825;max-width:480px}.card h1{font:300 22px/1.2 'Cormorant Garamond',serif;margin:0 0 6px;color:#d2b15c}.card p{color:#bba;margin:6px 0}a{color:#d2b15c;font-weight:500}</style>
+<div class="card">
+  <h1>Admin login set ✓</h1>
+  <p>Logged in as <code>steve@designerwallcoverings.com</code> · expires in 8 hours.</p>
+  <p><a href="${next}">→ Continue to ${next}</a> · <a href="/_devlogout">Log out</a></p>
+</div>`);
+});
+app.get('/_devlogout', (_req, res) => {
+  res.clearCookie('dw_auth');
+  res.type('text/plain').send('Logged out.\n');
+});
+
 // ── Start
 app.listen(PORT, '0.0.0.0', () => {
   console.log(`wallco.ai production server → http://0.0.0.0:${PORT}`);
diff --git a/src/admin.js b/src/admin.js
index c3e8e56..d6ca583 100644
--- a/src/admin.js
+++ b/src/admin.js
@@ -48,7 +48,7 @@ function gate(req, res, next) {
 
 // Top vendors with row count + 4 sample image URLs
 function topVendors(limit = 30) {
-  const sql = `
+  const inner = `
     WITH v AS (
       SELECT vendor_code, COUNT(*) AS n
       FROM vendor_catalog
@@ -62,26 +62,30 @@ function topVendors(limit = 30) {
         SELECT image_url FROM vendor_catalog WHERE vendor_code = v.vendor_code
           AND image_url IS NOT NULL AND image_url <> ''
         ORDER BY RANDOM() LIMIT 4) sample) AS samples
-    FROM v;`;
-  return JSON.parse(psql(`SELECT COALESCE(json_agg(t),'[]'::json) FROM (${sql}) t;`) || '[]');
+    FROM v`;
+  return JSON.parse(psql(`SELECT COALESCE(json_agg(t),'[]'::json) FROM (${inner}) t;`) || '[]');
 }
 
 function vendorProducts(code, { q, page = 1, per = 60 } = {}) {
+  // Filter cheaply: index on vendor_code first, then narrow string filters.
   const where = [`vendor_code=${esc(code)}`, `image_url IS NOT NULL`, `image_url <> ''`];
   if (q) {
     const qe = esc('%' + q + '%');
-    where.push(`(pattern_name ILIKE ${qe} OR composition ILIKE ${qe} OR collection ILIKE ${qe} OR specs::text ILIKE ${qe})`);
+    // Skip specs::text (slow full-doc scan). pattern_name/collection are short text.
+    where.push(`(pattern_name ILIKE ${qe} OR collection ILIKE ${qe} OR product_type ILIKE ${qe})`);
   }
   const offset = (Math.max(1, parseInt(page, 10)) - 1) * (parseInt(per, 10) || 60);
   const sql = `SELECT COALESCE(json_agg(t),'[]'::json) FROM (
     SELECT id, vendor_code, mfr_sku, pattern_name, color_name, collection,
-           product_type, image_url, product_url, composition, width, pattern_repeat
+           product_type, image_url, product_url, width, pattern_repeat
     FROM vendor_catalog WHERE ${where.join(' AND ')}
     ORDER BY pattern_name NULLS LAST, mfr_sku
     LIMIT ${per | 0} OFFSET ${offset}) t;`;
   return JSON.parse(psql(sql) || '[]');
 }
 
+// Aesthetic search — cap to 200 hits, search only pattern_name + collection + product_type
+// (no specs::text scan = ~50× faster on the 183K vendor_catalog).
 function aestheticSearch(tag, { limit = 100 } = {}) {
   const qe = esc('%' + tag + '%');
   const sql = `SELECT COALESCE(json_agg(t),'[]'::json) FROM (
@@ -89,8 +93,8 @@ function aestheticSearch(tag, { limit = 100 } = {}) {
            image_url, product_url
     FROM vendor_catalog
     WHERE image_url IS NOT NULL AND image_url <> ''
-      AND (pattern_name ILIKE ${qe} OR composition ILIKE ${qe} OR collection ILIKE ${qe} OR product_type ILIKE ${qe})
-    ORDER BY RANDOM() LIMIT ${limit | 0}) t;`;
+      AND (pattern_name ILIKE ${qe} OR collection ILIKE ${qe} OR product_type ILIKE ${qe})
+    LIMIT ${limit | 0}) t;`;
   return JSON.parse(psql(sql) || '[]');
 }
 
@@ -281,6 +285,75 @@ function mount(app) {
     try { removeInspiration(req.admin.email, parseInt((req.body||{}).id, 10)); res.json({ ok: true }); }
     catch (e) { res.status(500).json({ error: e.message }); }
   });
+
+  // ── Generate 3 wallco-originals from the admin's inspiration pool.
+  // STRICT NO-LEAK: only pattern_name + collection name are sent to the LLM and Replicate.
+  // Vendor names are NEVER included in the prompt that's stored or sent to Replicate.
+  app.post('/api/admin/inspiration/generate', gate, async (req, res) => {
+    try {
+      const items = inspirationFor(req.admin.email);
+      if (items.length === 0) return res.status(400).json({ error: 'pool_empty' });
+
+      // 1) Ask Ollama qwen3:14b to translate the items into 3 SDXL prompts (scrubbed)
+      const scrubbedList = items.slice(0, 8).map(i => `- ${i.pattern_name || 'untitled pattern'}`).join('\n');
+      const sys = `You are a wallpaper design prompt engineer. Given a short list of pattern names (vendor-anonymous), write 3 distinct SDXL prompts for *new* wallpaper designs that share the same aesthetic family but are NOT copies. Output ONLY a JSON array of 3 strings — each prompt is one paragraph ≤ 200 chars, describing motif + palette + mood + texture. Mention "seamless tile, 24-inch repeat, archival quality". Do NOT mention any vendor name or copy specific patterns.`;
+      const userMsg = `Aesthetic inspiration (names only):\n${scrubbedList}\n\nReturn a JSON array of 3 prompts.`;
+
+      const ollamaUrl = process.env.OLLAMA_URL || 'http://192.168.1.133:11434';
+      const ollResp = await fetch(`${ollamaUrl}/api/chat`, {
+        method: 'POST', headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          model: process.env.OLLAMA_MODEL || 'qwen3:14b',
+          messages: [{ role: 'system', content: sys }, { role: 'user', content: userMsg }],
+          stream: false, options: { temperature: 0.7 }
+        }),
+        signal: AbortSignal.timeout(60_000)
+      });
+      const ollText = (await ollResp.json()).message?.content || '';
+      let prompts = [];
+      try {
+        const m = ollText.match(/\[\s*"[\s\S]*?"\s*\]/);
+        if (m) prompts = JSON.parse(m[0]);
+      } catch {}
+      if (!Array.isArray(prompts) || prompts.length === 0) {
+        return res.status(502).json({ error: 'llm_returned_no_prompts', raw: ollText.slice(0, 400) });
+      }
+      prompts = prompts.slice(0, 3).filter(p => typeof p === 'string' && p.length > 20);
+
+      // 2) Fire SDXL via the wallco chat tool (re-uses Replicate path + palette extract + insert)
+      const { toolGenerateDesign } = (() => {
+        // chat.js exports mount(); not the tools. Inline the path here via psql + the same generate_designs.js stub.
+        return {};
+      })();
+      // Simpler: shell out to scripts/generate_designs.js with --prompts flag for each
+      const results = [];
+      for (const prompt of prompts) {
+        try {
+          const out = execSync(`node ${require('path').join(__dirname, '..', 'scripts/generate_designs.js')} --n 1 --kind seamless_tile --category mixed --prompts ${JSON.stringify(prompt)}`,
+            { encoding: 'utf8', timeout: 180_000 });
+          const idMatch = out.match(/IDs?:\s*(\d+)/);
+          if (idMatch) {
+            const id = parseInt(idMatch[1], 10);
+            const r = psql(`SELECT row_to_json(t) FROM (SELECT id, prompt, dominant_hex, local_path FROM spoon_all_designs WHERE id=${id}) t;`);
+            const row = JSON.parse(r);
+            // Attach lineage marker (admin pool inspiration). Note: prompts are scrubbed already.
+            psql(`UPDATE spoon_all_designs SET request_text='admin-pool: ' || ${esc(req.admin.email)} WHERE id=${id};`);
+            results.push({
+              id: row.id,
+              dominant_hex: row.dominant_hex,
+              prompt: row.prompt.slice(0, 200),
+              image_url: '/designs/img/' + (row.local_path || '').split('/').pop()
+            });
+          }
+        } catch (e) {
+          results.push({ error: e.message.slice(0, 200) });
+        }
+      }
+      res.json({ ok: true, generated: results.length, designs: results });
+    } catch (e) {
+      res.status(500).json({ error: e.message });
+    }
+  });
 }
 
 module.exports = { mount };

← d506fdf Add 5 Playwright e2e tests for /designs review flow — dual-m  ·  back to Wallco Ai  ·  wallco.ai: 'Pair this with' block on /design/:id — 3 nearest ccd3f38 →