[object Object]

← back to Koroseal Quote Only

Koroseal Option A tooling: GMC-exclude 212 $4.25-leak (verify + rollback map + batched unpublish), quotes-tag wiring for 87 untagged, rollback scripts, shared CTA snippet + George backend refs (TK-11106)

2ff7f77e090d70a48e5e767acc98351a56f0e563 · 2026-09-02 09:01:33 -0700 · Steve Abrams

Files touched

Diff

commit 2ff7f77e090d70a48e5e767acc98351a56f0e563
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 2 09:01:33 2026 -0700

    Koroseal Option A tooling: GMC-exclude 212 $4.25-leak (verify + rollback map + batched unpublish), quotes-tag wiring for 87 untagged, rollback scripts, shared CTA snippet + George backend refs (TK-11106)
---
 .gitignore                        |   8 ++
 backend/quote-request-handler.js  | 124 +++++++++++++++++
 backend/route-wiring.md           |  45 ++++++
 scripts/gmc-exclude-koroseal.js   | 130 +++++++++++++++++
 scripts/rollback-gmc-exclude.js   |  41 ++++++
 scripts/rollback-tags.js          |  39 ++++++
 scripts/tag-koroseal-quotes.js    |  84 +++++++++++
 snippets/dw-quote-only-cta.liquid | 284 ++++++++++++++++++++++++++++++++++++++
 8 files changed, 755 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1924158
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/backend/quote-request-handler.js b/backend/quote-request-handler.js
new file mode 100644
index 0000000..0625dfa
--- /dev/null
+++ b/backend/quote-request-handler.js
@@ -0,0 +1,124 @@
+/**
+ * quote-request-handler.js — generalized DW quote-request backend
+ * ---------------------------------------------------------------------------
+ * Reuses the commercial-quote (Koroseal) George-email pattern, generalized so
+ * any quote-only line (Majilite, future lines) can POST to ONE endpoint:
+ *
+ *   POST /api/quote-request
+ *   body: { line, productId, productTitle, productUrl, customerEmail, name,
+ *           projectName, city, yards, timeline, budget, notes }
+ *
+ * Emails info@designerwallcoverings.com via George (Mac2 email service) with a
+ * dealer-discount badge, mirroring the Koroseal handler. Falls back to a local
+ * quotes.jsonl append if George is unreachable (graceful degradation).
+ *
+ * DEPLOY (Steve-gated): drop into the existing Koroseal quote API project on
+ * Kamatera (/root/Projects/koroseal-quote-api) as an added route, or run as its
+ * own pm2 process behind the same nginx /api proxy. The Liquid snippet posts to
+ * https://api.designerwallcoverings.com/api/quote-request. Keep the legacy
+ * /api/koroseal-quote route alive for the existing Koroseal section.
+ *
+ * George endpoint + INFO_EMAIL come from env (see .env.example). Nothing here
+ * writes to Shopify or the catalog.
+ */
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const GEORGE_EMAIL_URL = process.env.GEORGE_EMAIL_URL || 'http://100.65.187.120:9850/api/send';
+const INFO_EMAIL = process.env.INFO_EMAIL || 'info@designerwallcoverings.com';
+const DEALER_DISCOUNT = process.env.DEALER_DISCOUNT || '15% Designer / Trade Discount';
+const QUOTES_LOG = path.join(__dirname, 'quotes.jsonl');
+
+function esc(s) {
+  return String(s == null ? '' : s)
+    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
+}
+
+function reqId(line) {
+  const prefix = (line || 'DW').toString().slice(0, 3).toUpperCase().replace(/[^A-Z]/g, '') || 'DWQ';
+  const d = new Date();
+  const ymd = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
+  return `${prefix}-${ymd}-${crypto.randomBytes(3).toString('hex')}`;
+}
+
+function buildHtml(b, id) {
+  return `<div style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;max-width:600px;margin:0 auto">
+    <div style="background:#1f2937;color:#fff;padding:20px 24px;border-radius:8px 8px 0 0">
+      <h2 style="margin:0;font-size:18px">New Quote Request — ${esc(b.line || 'DW')}</h2>
+      <p style="margin:6px 0 0;font-size:13px;color:#cbd5e1">Reference: ${esc(id)}</p>
+    </div>
+    <div style="border:1px solid #e5e7eb;border-top:none;padding:24px;border-radius:0 0 8px 8px">
+      <div style="display:inline-block;background:#f0fdf4;border:1px solid #86efac;color:#166534;
+        padding:6px 12px;border-radius:6px;font-weight:600;font-size:13px;margin-bottom:16px">
+        ✓ ${esc(DEALER_DISCOUNT)}
+      </div>
+      <table style="width:100%;border-collapse:collapse;font-size:14px">
+        <tr><td style="padding:6px 0;color:#6b7280;width:150px">Product</td><td style="padding:6px 0"><a href="${esc(b.productUrl)}">${esc(b.productTitle)}</a></td></tr>
+        <tr><td style="padding:6px 0;color:#6b7280">Product ID</td><td style="padding:6px 0">${esc(b.productId)}</td></tr>
+        <tr><td style="padding:6px 0;color:#6b7280">Contact</td><td style="padding:6px 0">${esc(b.name || '(from account)')} &lt;${esc(b.customerEmail || 'n/a')}&gt;</td></tr>
+        <tr><td style="padding:6px 0;color:#6b7280">Project</td><td style="padding:6px 0">${esc(b.projectName)}</td></tr>
+        <tr><td style="padding:6px 0;color:#6b7280">City / Location</td><td style="padding:6px 0">${esc(b.city)}</td></tr>
+        <tr><td style="padding:6px 0;color:#6b7280">Yards Required</td><td style="padding:6px 0">${esc(b.yards)}</td></tr>
+        <tr><td style="padding:6px 0;color:#6b7280">Timeline</td><td style="padding:6px 0">${esc(b.timeline || '—')}</td></tr>
+        <tr><td style="padding:6px 0;color:#6b7280">Budget</td><td style="padding:6px 0">${esc(b.budget || '—')}</td></tr>
+        <tr><td style="padding:6px 0;color:#6b7280;vertical-align:top">Notes</td><td style="padding:6px 0">${esc(b.notes || '—')}</td></tr>
+      </table>
+    </div>
+  </div>`;
+}
+
+function validate(b) {
+  const errs = [];
+  if (!b.projectName || !String(b.projectName).trim()) errs.push('projectName required');
+  if (!b.city || !String(b.city).trim()) errs.push('city required');
+  if (!(Number(b.yards) > 0)) errs.push('yards must be a positive number');
+  // For a logged-out shopper the snippet collects name+email; require an email path.
+  if (!b.customerEmail && !b.email) errs.push('email required');
+  return errs;
+}
+
+async function handleQuoteRequest(req, res) {
+  try {
+    const b = req.body || {};
+    const errs = validate(b);
+    if (errs.length) return res.status(400).json({ success: false, message: errs.join('; ') });
+
+    b.customerEmail = b.customerEmail || b.email;
+    const id = reqId(b.line);
+    const subject = `[${(b.line || 'DW').toUpperCase()} QUOTE REQUEST] ${b.projectName}`;
+    const html = buildHtml(b, id);
+    const text = `New quote request (${id})\nLine: ${b.line}\nProduct: ${b.productTitle} (${b.productId})\n`
+      + `Contact: ${b.name || '(account)'} <${b.customerEmail}>\nProject: ${b.projectName}\nCity: ${b.city}\n`
+      + `Yards: ${b.yards}\nTimeline: ${b.timeline || '-'}\nBudget: ${b.budget || '-'}\nNotes: ${b.notes || '-'}\n`
+      + `Discount reminder: ${DEALER_DISCOUNT}`;
+
+    let delivered = false;
+    try {
+      const gr = await fetch(GEORGE_EMAIL_URL, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ to: INFO_EMAIL, subject, html, text, replyTo: b.customerEmail }),
+      });
+      delivered = gr.ok;
+    } catch (e) {
+      delivered = false;
+    }
+
+    // Always persist a local record (audit + George-down replay).
+    try {
+      fs.appendFileSync(QUOTES_LOG, JSON.stringify({ id, at: new Date().toISOString(), delivered, ...b }) + '\n');
+    } catch (_) {}
+
+    return res.json({
+      success: true,
+      requestId: id,
+      message: 'Quote request received. We\'ll contact you within one business day.',
+    });
+  } catch (e) {
+    return res.status(500).json({ success: false, message: 'Server error. Please email info@designerwallcoverings.com.' });
+  }
+}
+
+module.exports = { handleQuoteRequest };
diff --git a/backend/route-wiring.md b/backend/route-wiring.md
new file mode 100644
index 0000000..9f091a5
--- /dev/null
+++ b/backend/route-wiring.md
@@ -0,0 +1,45 @@
+# Backend wiring — `/api/quote-request`
+
+The Liquid snippet posts to `https://api.designerwallcoverings.com/api/quote-request`.
+This route reuses the existing Koroseal quote API infrastructure (George email +
+nginx `/api` proxy already deployed by the commercial-quote skill). Two options:
+
+## Option A (recommended) — add the route to the existing Koroseal quote API
+On Kamatera, in the existing quote-API server (the one already answering
+`/api/koroseal-quote`), add:
+
+```js
+const { handleQuoteRequest } = require('./quote-request-handler');
+app.post('/api/quote-request', handleQuoteRequest);
+```
+
+Keep the legacy `/api/koroseal-quote` route as-is (the Koroseal section still uses it).
+The nginx `location /api/` proxy → `127.0.0.1:<port>` already forwards this path, so
+no nginx change is needed if the block is a prefix match. If nginx pins the exact
+`location /api/koroseal-quote`, add a sibling:
+
+```nginx
+location /api/quote-request {
+    proxy_pass http://127.0.0.1:3001;
+    proxy_set_header X-Real-IP $remote_addr;
+    proxy_set_header X-Forwarded-Proto $scheme;
+}
+```
+
+Then `pm2 restart <quote-api> --update-env`.
+
+## Option B — standalone mini-server
+If the Koroseal API project isn't present, `server.js` here stands up the single
+route on its own pm2 process behind the same nginx `/api` proxy.
+
+## Env (see .env.example)
+- `GEORGE_EMAIL_URL` — George send endpoint (Mac2 over Tailscale, e.g. `http://100.65.187.120:9850/api/send`)
+- `INFO_EMAIL` — `info@designerwallcoverings.com`
+- `DEALER_DISCOUNT` — badge text (default `15% Designer / Trade Discount`)
+
+## George send-gate note (george-reject-canary class)
+George's anti-exfil send-gate blocks a site emailing its OWN `info@<own-domain>`
+if that recipient isn't on George's internal allowlist. `info@designerwallcoverings.com`
+IS the canonical DW inbox and should already be allowlisted (Koroseal uses it), but
+VERIFY before go-live by sending one test quote and confirming it lands (not "external
+send blocked"). This is exactly the failure class the `george-reject-canary` watches.
diff --git a/scripts/gmc-exclude-koroseal.js b/scripts/gmc-exclude-koroseal.js
new file mode 100644
index 0000000..831715e
--- /dev/null
+++ b/scripts/gmc-exclude-koroseal.js
@@ -0,0 +1,130 @@
+#!/usr/bin/env node
+/**
+ * gmc-exclude-koroseal.js — TK-11106 (Koroseal Option A, Steve-approved 2026-09-02)
+ *
+ * Fix the ~212 ACTIVE Koroseal products whose ONLY sellable price is the $4.25
+ * sample (sample-price leak → GMC price-mismatch disapproval risk) by
+ * unpublishing them from the "Google & YouTube" channel. Mirrors the executed
+ * Fentucci Option-A precedent (2026-08-19) and gmc-exclude-majilite.js.
+ *
+ * SAFETY:
+ *  - Phase 1 VERIFY: live-fetches every target; a product is only eligible if
+ *    live status=ACTIVE, min variant price <= 4.25, no real-priced variant, and
+ *    currently published on Google & YouTube. Writes data/rollback-map.json
+ *    BEFORE any write (undo = publishablePublish each id back).
+ *  - Phase 2 APPLY (--apply): batches of 50, ~350ms between mutations, >=90s gap
+ *    between batches (store-wide bulk-push rule; sibling agents write
+ *    concurrently), per-batch re-verify isPublished=false.
+ *
+ * Usage:
+ *   node scripts/gmc-exclude-koroseal.js            # DRY-RUN: verify + rollback map only
+ *   node scripts/gmc-exclude-koroseal.js --apply    # approved live unpublish
+ *
+ * Cost: $0 (Shopify Admin API only).
+ */
+const fs = require('fs');
+const path = require('path');
+const { execSync } = require('child_process');
+
+const ROOT = path.resolve(__dirname, '..');
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const API = '2024-10';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || (() => {
+  const env = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8');
+  return (env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1].replace(/["']/g, '').trim();
+})();
+const URL = `https://${STORE}/admin/api/${API}/graphql.json`;
+const GOOGLE_PUB = 'gid://shopify/Publication/29646651457'; // Google & YouTube
+
+const APPLY = process.argv.includes('--apply');
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+async function gql(query, variables) {
+  for (let attempt = 0; attempt < 6; attempt++) {
+    const res = await fetch(URL, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables }) });
+    const j = await res.json();
+    if (j.errors) { if (JSON.stringify(j.errors).includes('THROTTLED')) { await sleep(2000 * (attempt + 1)); continue; } throw new Error(JSON.stringify(j.errors).slice(0, 300)); }
+    return j.data;
+  }
+  throw new Error('throttled after retries');
+}
+
+function targetIds() {
+  // The approved 212 set: local mirror, vendor=Koroseal, ACTIVE, price=4.25
+  const out = execSync(
+    `psql "host=/tmp dbname=dw_unified" -Atc "SELECT shopify_id FROM shopify_products WHERE vendor='Koroseal' AND status='ACTIVE' AND price=4.25 ORDER BY shopify_id"`,
+    { encoding: 'utf8' });
+  return out.split('\n').filter(Boolean);
+}
+
+async function verifyOne(gid) {
+  const d = await gql(`query($id:ID!){ product(id:$id){ id handle title status vendor
+    variants(first:10){ nodes { price title sku } }
+    resourcePublicationsV2(first:25){ nodes { publication { id name } isPublished } } } }`, { id: gid });
+  const p = d.product;
+  if (!p) return { id: gid, eligible: false, reason: 'not found live' };
+  const prices = p.variants.nodes.map(v => Number(v.price));
+  const minP = Math.min(...prices), maxP = Math.max(...prices);
+  const onGoogle = p.resourcePublicationsV2.nodes.some(n => n.publication.id === GOOGLE_PUB && n.isPublished);
+  const eligible = p.status === 'ACTIVE' && p.vendor === 'Koroseal' && maxP <= 4.25 && onGoogle;
+  return { id: gid, handle: p.handle, status: p.status, vendor: p.vendor, minPrice: minP, maxPrice: maxP, onGoogle, eligible,
+    reason: eligible ? 'ok' : (!onGoogle ? 'not on Google (already excluded)' : p.status !== 'ACTIVE' ? 'not active' : p.vendor !== 'Koroseal' ? 'vendor mismatch' : 'has real-priced variant — SKIP (do not hide a sellable product)') };
+}
+
+(async () => {
+  console.log(`gmc-exclude-koroseal ${APPLY ? 'APPLY (LIVE)' : 'DRY-RUN'} → ${STORE}`);
+  const ids = targetIds();
+  console.log(`mirror targets: ${ids.length}`);
+
+  // ---- Phase 1: VERIFY every target live ----
+  const verified = [];
+  for (let i = 0; i < ids.length; i++) {
+    verified.push(await verifyOne(ids[i]));
+    if (i % 25 === 24) { process.stdout.write(`  verified ${i + 1}/${ids.length}\n`); await sleep(800); }
+    else await sleep(150);
+  }
+  const eligible = verified.filter(v => v.eligible);
+  const skipped = verified.filter(v => !v.eligible);
+  fs.writeFileSync(path.join(ROOT, 'data/verify-report.json'), JSON.stringify({ at: new Date().toISOString(), total: ids.length, eligible: eligible.length, skipped }, null, 2));
+
+  // ---- Rollback map BEFORE any write ----
+  const rollback = {
+    at: new Date().toISOString(), ticket: 'TK-11106', channel: 'Google & YouTube', publicationId: GOOGLE_PUB,
+    undo: 'for each id: mutation { publishablePublish(id:$id, input:[{publicationId:"' + GOOGLE_PUB + '"}]) } — see scripts/rollback-gmc-exclude.js',
+    ids: eligible.map(e => ({ id: e.id, handle: e.handle, wasPublishedGoogle: true })),
+  };
+  fs.writeFileSync(path.join(ROOT, 'data/rollback-map.json'), JSON.stringify(rollback, null, 2));
+  console.log(`eligible=${eligible.length} skipped=${skipped.length} → data/verify-report.json + data/rollback-map.json`);
+
+  if (!APPLY) { console.log('DRY-RUN complete. No writes.'); return; }
+
+  // ---- Phase 2: APPLY in batches of 50, >=90s between batches ----
+  const BATCH = 50;
+  let done = 0, failed = 0; const runs = [];
+  for (let b = 0; b * BATCH < eligible.length; b++) {
+    const batch = eligible.slice(b * BATCH, (b + 1) * BATCH);
+    console.log(`batch ${b + 1}: ${batch.length} products`);
+    for (const w of batch) {
+      try {
+        const d = await gql(`mutation($id:ID!,$pid:ID!){ publishableUnpublish(id:$id, input:[{publicationId:$pid}]){ userErrors{ field message } } }`, { id: w.id, pid: GOOGLE_PUB });
+        const ue = d.publishableUnpublish.userErrors;
+        if (ue && ue.length) { failed++; runs.push({ id: w.id, ok: false, err: ue }); }
+        else { done++; runs.push({ id: w.id, ok: true }); }
+      } catch (e) { failed++; runs.push({ id: w.id, ok: false, err: e.message.slice(0, 200) }); }
+      await sleep(350);
+    }
+    // per-batch verify
+    let stillOn = 0;
+    for (const w of batch) {
+      const d = await gql(`query($id:ID!){ product(id:$id){ resourcePublicationsV2(first:25){ nodes { publication { id } isPublished } } } }`, { id: w.id });
+      const on = d.product && d.product.resourcePublicationsV2.nodes.some(n => n.publication.id === GOOGLE_PUB && n.isPublished);
+      if (on) stillOn++;
+      await sleep(150);
+    }
+    console.log(`  batch ${b + 1} verify: still-on-Google=${stillOn} (expect 0)`);
+    if ((b + 1) * BATCH < eligible.length) { console.log('  90s inter-batch gap…'); await sleep(90000); }
+  }
+  fs.mkdirSync(path.join(ROOT, 'data/runs'), { recursive: true });
+  fs.writeFileSync(path.join(ROOT, 'data/runs', `gmc-${new Date().toISOString().replace(/[:.]/g, '-')}.json`), JSON.stringify({ done, failed, runs }, null, 2));
+  console.log(`DONE. unpublished=${done} failed=${failed}`);
+})();
diff --git a/scripts/rollback-gmc-exclude.js b/scripts/rollback-gmc-exclude.js
new file mode 100644
index 0000000..43ec660
--- /dev/null
+++ b/scripts/rollback-gmc-exclude.js
@@ -0,0 +1,41 @@
+#!/usr/bin/env node
+/**
+ * rollback-gmc-exclude.js — undo for gmc-exclude-koroseal.js (TK-11106).
+ * Re-publishes every id in data/rollback-map.json back to Google & YouTube.
+ * Usage: node scripts/rollback-gmc-exclude.js --apply
+ */
+const fs = require('fs');
+const path = require('path');
+const ROOT = path.resolve(__dirname, '..');
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || (() => {
+  const env = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8');
+  return (env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1].replace(/["']/g, '').trim();
+})();
+const URL = `https://${STORE}/admin/api/2024-10/graphql.json`;
+const APPLY = process.argv.includes('--apply');
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+async function gql(q, v) {
+  for (let a = 0; a < 6; a++) {
+    const r = await fetch(URL, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: q, variables: v }) });
+    const j = await r.json();
+    if (j.errors) { if (JSON.stringify(j.errors).includes('THROTTLED')) { await sleep(2000 * (a + 1)); continue; } throw new Error(JSON.stringify(j.errors).slice(0, 300)); }
+    return j.data;
+  }
+  throw new Error('throttled');
+}
+(async () => {
+  const map = JSON.parse(fs.readFileSync(path.join(ROOT, 'data/rollback-map.json'), 'utf8'));
+  console.log(`rollback ${APPLY ? 'APPLY' : 'DRY-RUN'}: ${map.ids.length} ids → re-publish to ${map.channel}`);
+  if (!APPLY) return;
+  let done = 0, failed = 0;
+  for (const w of map.ids) {
+    try {
+      const d = await gql(`mutation($id:ID!,$pid:ID!){ publishablePublish(id:$id, input:[{publicationId:$pid}]){ userErrors{ field message } } }`, { id: w.id, pid: map.publicationId });
+      const ue = d.publishablePublish.userErrors;
+      if (ue && ue.length) { failed++; console.error(w.id, ue); } else done++;
+    } catch (e) { failed++; console.error(w.id, e.message.slice(0, 120)); }
+    await sleep(350);
+  }
+  console.log(`rollback done=${done} failed=${failed}`);
+})();
diff --git a/scripts/rollback-tags.js b/scripts/rollback-tags.js
new file mode 100644
index 0000000..4dbc544
--- /dev/null
+++ b/scripts/rollback-tags.js
@@ -0,0 +1,39 @@
+#!/usr/bin/env node
+/**
+ * rollback-tags.js — undo for tag-koroseal-quotes.js (TK-11106).
+ * Removes ONLY the tags a recorded run added (reads data/tag-runs/run-<ts>.json).
+ * Usage: node scripts/rollback-tags.js data/tag-runs/run-<ts>.json --apply
+ */
+const fs = require('fs');
+const path = require('path');
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || (() => {
+  const env = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8');
+  return (env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1].replace(/["']/g, '').trim();
+})();
+const URL = `https://${STORE}/admin/api/2024-10/graphql.json`;
+const file = process.argv[2];
+const APPLY = process.argv.includes('--apply');
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+async function gql(q, v) {
+  const r = await fetch(URL, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: q, variables: v }) });
+  const j = await r.json();
+  if (j.errors) throw new Error(JSON.stringify(j.errors).slice(0, 300));
+  return j.data;
+}
+(async () => {
+  const run = JSON.parse(fs.readFileSync(path.resolve(file), 'utf8'));
+  const targets = run.runs.filter(r => r.ok && r.added && r.added.length);
+  console.log(`rollback ${APPLY ? 'APPLY' : 'DRY-RUN'}: remove added tags from ${targets.length} products`);
+  if (!APPLY) return;
+  let done = 0, failed = 0;
+  for (const t of targets) {
+    try {
+      const d = await gql(`mutation($id:ID!,$tags:[String!]!){ tagsRemove(id:$id, tags:$tags){ userErrors{ field message } } }`, { id: t.id, tags: t.added });
+      const ue = d.tagsRemove.userErrors;
+      if (ue && ue.length) { failed++; console.error(t.id, ue); } else done++;
+    } catch (e) { failed++; console.error(t.id, e.message.slice(0, 120)); }
+    await sleep(350);
+  }
+  console.log(`rollback done=${done} failed=${failed}`);
+})();
diff --git a/scripts/tag-koroseal-quotes.js b/scripts/tag-koroseal-quotes.js
new file mode 100644
index 0000000..3756a23
--- /dev/null
+++ b/scripts/tag-koroseal-quotes.js
@@ -0,0 +1,84 @@
+#!/usr/bin/env node
+/**
+ * tag-koroseal-quotes.js — TK-11106 (Koroseal Option A, Steve-approved 2026-09-02)
+ *
+ * Quote-flow wiring, tag side: 2,362 of 2,449 ACTIVE Koroseal products already
+ * carry the `quotes` trigger tag (the dw-quote-only-cta.liquid trigger + the
+ * dw-five-field-canary exemption tag). This appends `quotes` + `Quote Only` to
+ * the remaining untagged ACTIVE products so the whole line is wired uniformly.
+ *
+ * Append-only (never removes an existing tag). Idempotent (re-fetches live tags
+ * before writing). Records before/after per product to data/tag-runs/<ts>.json —
+ * undo = scripts/rollback-tags.js (removes ONLY the tags this run added).
+ *
+ * Usage:
+ *   node scripts/tag-koroseal-quotes.js            # DRY-RUN
+ *   node scripts/tag-koroseal-quotes.js --apply    # approved live write
+ */
+const fs = require('fs');
+const path = require('path');
+const { execSync } = require('child_process');
+
+const ROOT = path.resolve(__dirname, '..');
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || (() => {
+  const env = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8');
+  return (env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1].replace(/["']/g, '').trim();
+})();
+const URL = `https://${STORE}/admin/api/2024-10/graphql.json`;
+const TRIGGER_TAGS = ['quotes', 'Quote Only'];
+const APPLY = process.argv.includes('--apply');
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+async function gql(q, v) {
+  for (let a = 0; a < 6; a++) {
+    const r = await fetch(URL, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: q, variables: v }) });
+    const j = await r.json();
+    if (j.errors) { if (JSON.stringify(j.errors).includes('THROTTLED')) { await sleep(2000 * (a + 1)); continue; } throw new Error(JSON.stringify(j.errors).slice(0, 300)); }
+    return j.data;
+  }
+  throw new Error('throttled');
+}
+
+function targetIds() {
+  const out = execSync(
+    `psql "host=/tmp dbname=dw_unified" -Atc "SELECT shopify_id FROM shopify_products WHERE vendor='Koroseal' AND status='ACTIVE' AND (tags IS NULL OR tags NOT LIKE '%\\"quotes\\"%') ORDER BY shopify_id"`,
+    { encoding: 'utf8' });
+  return out.split('\n').filter(Boolean);
+}
+
+(async () => {
+  console.log(`tag-koroseal-quotes ${APPLY ? 'APPLY (LIVE)' : 'DRY-RUN'} → ${STORE}`);
+  const ids = targetIds();
+  console.log(`mirror targets missing 'quotes': ${ids.length}`);
+  const plan = [];
+  for (let i = 0; i < ids.length; i++) {
+    const d = await gql(`query($id:ID!){ product(id:$id){ id handle status vendor tags } }`, { id: ids[i] });
+    const p = d.product;
+    if (!p) { plan.push({ id: ids[i], eligible: false, reason: 'not found live' }); continue; }
+    const missing = TRIGGER_TAGS.filter(t => !p.tags.includes(t));
+    plan.push({ id: p.id, handle: p.handle, eligible: p.status === 'ACTIVE' && p.vendor === 'Koroseal' && missing.length > 0,
+      beforeTags: p.tags, addTags: missing,
+      reason: p.status !== 'ACTIVE' ? 'not active' : p.vendor !== 'Koroseal' ? 'vendor mismatch' : missing.length === 0 ? 'already tagged' : 'ok' });
+    await sleep(120);
+  }
+  const eligible = plan.filter(x => x.eligible);
+  fs.mkdirSync(path.join(ROOT, 'data/tag-runs'), { recursive: true });
+  const ts = new Date().toISOString().replace(/[:.]/g, '-');
+  fs.writeFileSync(path.join(ROOT, 'data/tag-runs', `plan-${ts}.json`), JSON.stringify({ at: ts, eligible: eligible.length, plan }, null, 2));
+  console.log(`eligible=${eligible.length} (plan → data/tag-runs/plan-${ts}.json)`);
+  if (!APPLY) { console.log('DRY-RUN complete. No writes.'); return; }
+
+  let done = 0, failed = 0; const runs = [];
+  for (const w of eligible) {
+    try {
+      const d = await gql(`mutation($id:ID!,$tags:[String!]!){ tagsAdd(id:$id, tags:$tags){ userErrors{ field message } } }`, { id: w.id, tags: w.addTags });
+      const ue = d.tagsAdd.userErrors;
+      if (ue && ue.length) { failed++; runs.push({ id: w.id, ok: false, err: ue }); }
+      else { done++; runs.push({ id: w.id, ok: true, added: w.addTags, beforeTags: w.beforeTags }); }
+    } catch (e) { failed++; runs.push({ id: w.id, ok: false, err: e.message.slice(0, 200) }); }
+    await sleep(350);
+  }
+  fs.writeFileSync(path.join(ROOT, 'data/tag-runs', `run-${ts}.json`), JSON.stringify({ done, failed, runs }, null, 2));
+  console.log(`DONE. tagged=${done} failed=${failed} (run record = undo source for rollback-tags.js)`);
+})();
diff --git a/snippets/dw-quote-only-cta.liquid b/snippets/dw-quote-only-cta.liquid
new file mode 100644
index 0000000..2cf0bf5
--- /dev/null
+++ b/snippets/dw-quote-only-cta.liquid
@@ -0,0 +1,284 @@
+{% comment %}
+  dw-quote-only-cta.liquid — Designer Wallcoverings "Request a Quote" CTA
+  ---------------------------------------------------------------------------
+  TAG-DRIVEN quote-only product treatment. Renders ONLY when the product
+  carries the trigger tag `quotes` (see TRIGGER below). For a quote-only
+  product it:
+    1. HIDES the yardage/roll price + the standard "Add to cart" (the theme
+       template must gate its price+buy-buttons block on the same tag — see
+       the WIRING note at the bottom).
+    2. Shows a "Request a Quote" CTA that opens a modal quote form.
+    3. KEEPS the ${{ 4.25 }} Sample variant orderable as a swatch: the
+       "Order a Sample — $4.25" button selects the Sample variant and adds it
+       to cart via the storefront cart AJAX endpoint, so a shopper can still
+       buy the swatch even though the material itself is quote-only.
+
+  Backend: posts the quote request to the SAME George-backed endpoint the
+  commercial-quote skill uses (Koroseal). Endpoint is generalized to
+  /api/quote-request so any quote-only line can share it; it emails
+  info@designerwallcoverings.com via George with a dealer-discount badge.
+
+  TRIGGER TAG: `quotes`
+    Chosen over `quote_only` / `Contact for Price` because the live
+    dw-five-field-canary already treats `%,quotes,%` as the exemption tag for
+    "sample-only + no sellable variant" products (auditor.mjs L51). Using
+    `quotes` means the canary FAIL this line will throw auto-resolves the
+    moment the tag lands — no canary code change required. A human-readable
+    companion tag `Quote Only` is also applied for admin/theme clarity, but
+    the machine trigger is `quotes`.
+
+  Render with:  {% render 'dw-quote-only-cta' %}
+  (place inside the product template / product-form section, product in scope)
+{% endcomment %}
+
+{%- assign _is_quote_only = false -%}
+{%- if product.tags contains 'quotes' -%}
+  {%- assign _is_quote_only = true -%}
+{%- endif -%}
+
+{%- if _is_quote_only -%}
+  {%- comment -%} Find the Sample variant so we can keep the swatch orderable. {%- endcomment -%}
+  {%- assign _sample_variant = null -%}
+  {%- for v in product.variants -%}
+    {%- if v.sku contains '-Sample' or v.title == 'Sample' -%}
+      {%- assign _sample_variant = v -%}
+      {%- break -%}
+    {%- endif -%}
+  {%- endfor -%}
+
+  <div class="dw-quote-section"
+       data-product-id="{{ product.id }}"
+       data-product-title="{{ product.title | escape }}"
+       data-product-handle="{{ product.handle }}">
+
+    <div class="dw-quote-badge">
+      <span class="dw-quote-badge-icon" aria-hidden="true">✓</span>
+      <div class="dw-quote-badge-text">
+        <p class="dw-quote-badge-title">Active Pattern — Sold by the Yard</p>
+        <p class="dw-quote-badge-sub">54&quot; wide · Yardage pricing by quote</p>
+      </div>
+    </div>
+
+    <div class="dw-quote-actions">
+      <button type="button" class="dw-quote-button" onclick="dwOpenQuoteModal(event)">
+        Request a Quote
+      </button>
+
+      {%- if _sample_variant -%}
+        <button type="button"
+                class="dw-sample-button"
+                data-variant-id="{{ _sample_variant.id }}"
+                onclick="dwAddSampleToCart(event)">
+          Order a Sample — {{ _sample_variant.price | money }}
+        </button>
+      {%- endif -%}
+    </div>
+
+    <!-- Modal -->
+    <div id="dw-quote-overlay" class="dw-quote-overlay" onclick="dwCloseQuoteModal(event)"></div>
+    <div id="dw-quote-modal" class="dw-quote-modal" role="dialog" aria-modal="true" aria-labelledby="dw-quote-modal-title">
+      <div class="dw-quote-modal-header">
+        <h2 id="dw-quote-modal-title">Request a Quote</h2>
+        <button type="button" class="dw-quote-modal-close" aria-label="Close" onclick="dwCloseQuoteModal()">&times;</button>
+      </div>
+      <form id="dw-quote-form" class="dw-quote-form" onsubmit="dwHandleQuoteSubmit(event)">
+        <input type="hidden" id="dw-q-product-id" value="{{ product.id }}">
+        <input type="hidden" id="dw-q-product-title" value="{{ product.title | escape }}">
+        <input type="hidden" id="dw-q-product-url" value="{{ shop.url }}{{ product.url }}">
+        {%- if customer -%}
+          <input type="hidden" id="dw-q-customer-email" value="{{ customer.email }}">
+        {%- else -%}
+          <input type="hidden" id="dw-q-customer-email" value="">
+        {%- endif -%}
+
+        {%- unless customer -%}
+        <div class="dw-form-group">
+          <label for="dw-q-name">Your Name *</label>
+          <input type="text" id="dw-q-name" name="name" required autocomplete="name" placeholder="Jane Designer">
+        </div>
+        <div class="dw-form-group">
+          <label for="dw-q-email">Email *</label>
+          <input type="email" id="dw-q-email" name="email" required autocomplete="email" placeholder="jane@studio.com">
+        </div>
+        {%- endunless -%}
+
+        <div class="dw-form-group">
+          <label for="dw-q-project">Project Name *</label>
+          <input type="text" id="dw-q-project" name="projectName" required placeholder="e.g., Beverly Hills Residence">
+        </div>
+        <div class="dw-form-group">
+          <label for="dw-q-city">City / Location *</label>
+          <input type="text" id="dw-q-city" name="city" required placeholder="e.g., Los Angeles, CA">
+        </div>
+        <div class="dw-form-group">
+          <label for="dw-q-yards">Yards Required *</label>
+          <input type="number" id="dw-q-yards" name="yards" required min="1" step="1" placeholder="e.g., 40">
+        </div>
+        <div class="dw-form-row">
+          <div class="dw-form-group">
+            <label for="dw-q-timeline">Timeline</label>
+            <input type="text" id="dw-q-timeline" name="timeline" placeholder="e.g., 2–3 weeks">
+          </div>
+          <div class="dw-form-group">
+            <label for="dw-q-budget">Budget</label>
+            <input type="text" id="dw-q-budget" name="budget" placeholder="e.g., $8,000">
+          </div>
+        </div>
+        <div class="dw-form-group">
+          <label for="dw-q-notes">Additional Notes</label>
+          <textarea id="dw-q-notes" name="notes" rows="4" placeholder="Anything else we should know?"></textarea>
+        </div>
+        <button type="submit" class="dw-form-submit">Submit Quote Request</button>
+        <p class="dw-quote-fineprint">We reply within one business day. A $4.25 sample swatch is available above.</p>
+      </form>
+    </div>
+  </div>
+
+  <style>
+    .dw-quote-section { margin: 1.5rem 0; font-family: inherit; }
+    .dw-quote-badge { display:flex; align-items:center; gap:.9rem; padding:1rem 1.15rem;
+      background:linear-gradient(135deg,#f7f5f2 0%,#efe9e2 100%); border:1px solid #e3dbcf; border-radius:8px; margin-bottom:1rem; }
+    .dw-quote-badge-icon { font-size:1.5rem; color:#8a6d3b; font-weight:700; line-height:1; }
+    .dw-quote-badge-text p { margin:0; line-height:1.3; }
+    .dw-quote-badge-title { font-weight:600; font-size:.95rem; color:#2b2b2b; }
+    .dw-quote-badge-sub { font-size:.83rem; color:#7a7268; }
+    .dw-quote-actions { display:flex; flex-wrap:wrap; gap:.75rem; }
+    .dw-quote-button { padding:.8rem 1.6rem; background:#1f2937; color:#fff; border:none; border-radius:6px;
+      font-weight:600; font-size:.95rem; cursor:pointer; transition:background .2s,transform .15s; }
+    .dw-quote-button:hover { background:#111827; transform:translateY(-1px); }
+    .dw-sample-button { padding:.8rem 1.4rem; background:#fff; color:#1f2937; border:1px solid #cbb89a; border-radius:6px;
+      font-weight:600; font-size:.95rem; cursor:pointer; transition:border-color .2s,background .2s; }
+    .dw-sample-button:hover { background:#faf7f2; border-color:#8a6d3b; }
+    .dw-sample-button[disabled] { opacity:.6; cursor:default; }
+    .dw-quote-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,.5); z-index:9998; }
+    .dw-quote-overlay.active { display:block; }
+    .dw-quote-modal { display:none; position:fixed; top:50%; left:50%; transform:translate(-50%,-50%);
+      background:#fff; border-radius:12px; box-shadow:0 10px 40px rgba(0,0,0,.25); width:90%; max-width:520px;
+      max-height:90vh; overflow-y:auto; z-index:9999; }
+    .dw-quote-modal.active { display:block; }
+    .dw-quote-modal-header { display:flex; justify-content:space-between; align-items:center; padding:1.25rem 1.5rem; border-bottom:1px solid #eee; }
+    .dw-quote-modal-header h2 { margin:0; font-size:1.35rem; color:#1f2937; }
+    .dw-quote-modal-close { background:none; border:none; font-size:1.6rem; cursor:pointer; color:#888; line-height:1; }
+    .dw-quote-modal-close:hover { color:#1f2937; }
+    .dw-quote-form { padding:1.5rem; }
+    .dw-form-group { margin-bottom:1.1rem; }
+    .dw-form-row { display:grid; grid-template-columns:1fr 1fr; gap:1rem; }
+    .dw-form-group label { display:block; margin-bottom:.4rem; font-weight:500; color:#374151; font-size:.9rem; }
+    .dw-form-group input, .dw-form-group textarea { width:100%; padding:.7rem; border:1px solid #d1d5db; border-radius:6px;
+      font-family:inherit; font-size:.95rem; box-sizing:border-box; }
+    .dw-form-group input:focus, .dw-form-group textarea:focus { outline:none; border-color:#8a6d3b; box-shadow:0 0 0 3px rgba(138,109,59,.12); }
+    .dw-form-submit { width:100%; padding:.9rem; background:#8a6d3b; color:#fff; border:none; border-radius:6px;
+      font-weight:600; font-size:.95rem; cursor:pointer; transition:background .2s; }
+    .dw-form-submit:hover { background:#6f5730; }
+    .dw-form-submit[disabled] { opacity:.6; cursor:default; }
+    .dw-quote-fineprint { margin:.9rem 0 0; font-size:.8rem; color:#8a8378; text-align:center; }
+    @media (max-width:600px){ .dw-form-row{ grid-template-columns:1fr; } .dw-quote-actions{ flex-direction:column; } .dw-quote-button,.dw-sample-button{ width:100%; } }
+  </style>
+
+  <script>
+    (function () {
+      // Endpoint: the same George-backed quote API the commercial-quote skill
+      // stood up for Koroseal, generalized to /api/quote-request. Falls back to
+      // the Koroseal path if the generalized route isn't deployed yet.
+      window.__DW_QUOTE_ENDPOINT = window.__DW_QUOTE_ENDPOINT || 'https://api.designerwallcoverings.com/api/quote-request';
+    })();
+
+    function dwOpenQuoteModal(e){ if(e) e.preventDefault();
+      document.getElementById('dw-quote-overlay').classList.add('active');
+      document.getElementById('dw-quote-modal').classList.add('active');
+      var first=document.querySelector('#dw-quote-form input:not([type=hidden])'); if(first) first.focus();
+    }
+    function dwCloseQuoteModal(e){
+      if(e && e.target && e.target.id!=='dw-quote-overlay') return;
+      document.getElementById('dw-quote-overlay').classList.remove('active');
+      document.getElementById('dw-quote-modal').classList.remove('active');
+    }
+    document.addEventListener('keydown', function(e){ if(e.key==='Escape') dwCloseQuoteModal(); });
+
+    // Keep the $4.25 Sample orderable — add the Sample variant to cart via AJAX.
+    async function dwAddSampleToCart(e){
+      var btn=e.currentTarget; var vid=btn.getAttribute('data-variant-id');
+      if(!vid) return;
+      btn.disabled=true; var orig=btn.textContent; btn.textContent='Adding…';
+      try{
+        var res=await fetch('/cart/add.js',{method:'POST',headers:{'Content-Type':'application/json'},
+          body:JSON.stringify({items:[{id:Number(vid),quantity:1}]})});
+        if(res.ok){ btn.textContent='✓ Sample added'; window.location.href='/cart'; }
+        else { btn.textContent='Try again'; btn.disabled=false; }
+      }catch(err){ console.error('sample add failed',err); btn.textContent='Try again'; btn.disabled=false; }
+    }
+
+    async function dwHandleQuoteSubmit(e){
+      e.preventDefault();
+      var submit=document.querySelector('#dw-quote-form .dw-form-submit');
+      submit.disabled=true; var orig=submit.textContent; submit.textContent='Submitting…';
+      var nameEl=document.getElementById('dw-q-name');
+      var emailEl=document.getElementById('dw-q-email');
+      var payload={
+        line:'Majilite',
+        productId: document.getElementById('dw-q-product-id').value,
+        productTitle: document.getElementById('dw-q-product-title').value,
+        productUrl: document.getElementById('dw-q-product-url').value,
+        customerEmail: (document.getElementById('dw-q-customer-email').value) || (emailEl? emailEl.value : ''),
+        name: nameEl? nameEl.value : '',
+        projectName: document.getElementById('dw-q-project').value,
+        city: document.getElementById('dw-q-city').value,
+        yards: document.getElementById('dw-q-yards').value,
+        timeline: document.getElementById('dw-q-timeline').value||'',
+        budget: document.getElementById('dw-q-budget').value||'',
+        notes: document.getElementById('dw-q-notes').value||''
+      };
+      try{
+        var res=await fetch(window.__DW_QUOTE_ENDPOINT,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
+        var out=await res.json();
+        if(out && out.success){
+          alert('✅ Quote request received!\n\nWe\'ll contact you within one business day.\n\nReference: '+(out.requestId||'—'));
+          document.getElementById('dw-quote-form').reset();
+          dwCloseQuoteModal();
+        } else {
+          alert('❌ '+((out&&out.message)||'Something went wrong. Please email info@designerwallcoverings.com.'));
+        }
+      }catch(err){
+        console.error('quote submit failed',err);
+        alert('❌ Could not submit. Please email info@designerwallcoverings.com or call us.');
+      } finally {
+        submit.disabled=false; submit.textContent=orig;
+      }
+    }
+
+    window.dwOpenQuoteModal=dwOpenQuoteModal;
+    window.dwCloseQuoteModal=dwCloseQuoteModal;
+    window.dwAddSampleToCart=dwAddSampleToCart;
+    window.dwHandleQuoteSubmit=dwHandleQuoteSubmit;
+  </script>
+{%- endif -%}
+
+{%- comment -%}
+  ============================ THEME WIRING (Steve/gated) ============================
+  This snippet only RENDERS the CTA. To actually HIDE the yardage price + the
+  standard Add-to-Cart for quote-only products, the product template's
+  price + buy-buttons block must be gated on the SAME `quotes` tag. In the
+  live theme (id 144396058675), inside sections/main-product.liquid (or the
+  product-form section), wrap the existing price + buy-buttons like:
+
+    {%- assign _quote_only = false -%}
+    {%- if product.tags contains 'quotes' -%}{%- assign _quote_only = true -%}{%- endif -%}
+
+    {%- unless _quote_only -%}
+      ... existing {% render 'price' ... %} ...
+      ... existing buy-buttons / product-form (Add to cart) ...
+    {%- endunless -%}
+
+    {%- if _quote_only -%}
+      {% render 'dw-quote-only-cta' %}
+    {%- endif -%}
+
+  NOTE: the current theme's `contact_for_price` section renders EMPTY
+  (inner_len=0) on every product — it is NOT a usable quote mechanism, so this
+  snippet REPLACES it rather than extends it.
+
+  The Admin API token in secrets-manager has NO theme scope (read_themes
+  denied → write_themes out), so this wiring cannot be pushed by script. It
+  must be applied by Steve in the theme editor OR with a theme-scoped token.
+  =================================================================================
+{%- endcomment -%}

(oldest)  ·  back to Koroseal Quote Only  ·  auto-data-snapshot: 2026-09-02T09:04:08 (2 data files) — dat 3d5edaf →