← 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
A .gitignoreA backend/quote-request-handler.jsA backend/route-wiring.mdA scripts/gmc-exclude-koroseal.jsA scripts/rollback-gmc-exclude.jsA scripts/rollback-tags.jsA scripts/tag-koroseal-quotes.jsA snippets/dw-quote-only-cta.liquid
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, '&').replace(/</g, '<').replace(/>/g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''');
+}
+
+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)')} <${esc(b.customerEmail || 'n/a')}></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" 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()">×</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 →