← back to Dw Unbuyable Recovery Pilot
tk11042-quote-tag/build-plan.mjs
89 lines
#!/usr/bin/env node
/**
* TK-11042 — READ-ONLY plan builder for the quote-CTA tag normalization.
*
* PROBLEM (verified live, not from the mirror — mirror tags proved stale):
* snippets/dw-quote-only-cta.liquid renders "Request a Quote" ONLY when the
* Liquid array test `product.tags contains 'quotes'` is true (exact element
* match; the snippet header states `quotes` was chosen OVER `quote_only` /
* `Contact for Price`). Products that carry the *intent* tags
* `quote-only` / `contact-for-price` / `Needs-Price` but NOT `quotes`
* therefore render with no price, no Add-to-cart and no quote CTA = a
* customer-facing DEAD END.
*
* This script only READS the Shopify Admin API and writes plan.json.
* It never mutates anything. Run it immediately before repair.mjs so the plan
* reflects current live state.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const DIR = path.dirname(fileURLToPath(import.meta.url));
const VENDORS = ['Phillipe Romano', 'Marburg'];
const env = Object.fromEntries(
fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8')
.split('\n').filter(l => l.includes('='))
.map(l => [l.slice(0, l.indexOf('=')).trim(), l.slice(l.indexOf('=') + 1).trim()]));
const SHOP = (env.SHOPIFY_STORE_DOMAIN || env.SHOPIFY_STORE).replace(/^https?:\/\//, '');
const TOKEN = env.SHOPIFY_ADMIN_TOKEN;
const hdr = { 'X-Shopify-Access-Token': TOKEN };
const sleep = ms => new Promise(r => setTimeout(r, ms));
const lc = t => (t || '').trim().toLowerCase();
const hasTag = (tags, name) => tags.some(t => lc(t) === name);
const isSellable = vs => vs.some(v => !/sample/i.test(v.title || '') && Number(v.price) > 4.50);
const shape = vs => [...new Set(vs.map(v => v.title))].sort().join('|');
// theme's own sample lookup: v.sku contains '-Sample' OR v.title == 'Sample'
const findSample = vs => vs.find(v => /-sample/i.test(v.sku || '') || v.title === 'Sample');
async function pull(vendor) {
let url = `https://${SHOP}/admin/api/2024-10/products.json?limit=250&status=active`
+ `&vendor=${encodeURIComponent(vendor)}&fields=id,handle,title,vendor,tags,variants`;
const out = [];
while (url) {
const r = await fetch(url, { headers: hdr });
if (!r.ok) throw new Error(`${vendor} ${r.status} ${await r.text()}`);
const j = await r.json();
out.push(...j.products);
const m = (r.headers.get('link') || '').match(/<([^>]+)>;\s*rel="next"/);
url = m ? m[1] : null;
await sleep(550);
}
return out;
}
const plan = { builtAt: new Date().toISOString(), shop: SHOP, tranche1: [], tranche2: [], excluded: [] };
for (const vendor of VENDORS) {
const rows = await pull(vendor);
process.stderr.write(`${vendor}: ${rows.length} active\n`);
for (const p of rows) {
const tags = (p.tags || '').split(',').map(s => s.trim()).filter(Boolean);
const vs = p.variants || [];
if (hasTag(tags, 'quotes')) continue; // already correct
if (isSellable(vs)) continue; // already buyable
const rec = { id: p.id, handle: p.handle, vendor: p.vendor, tagsBefore: p.tags || '' };
// memo-sample products (single 'Default Title' variant @ $4.25) are the
// product itself sold as a sample — correct-by-design, NOT a dead end.
if (shape(vs) === 'Default Title') { plan.excluded.push({ ...rec, why: 'memo-sample (orderable $4.25, correct-by-design)' }); continue; }
const sample = findSample(vs);
// the CTA needs a theme-detectable Sample variant to render its sample button
if (!sample) { plan.excluded.push({ ...rec, why: 'no theme-detectable Sample variant' }); continue; }
rec.sampleVariantId = sample.id;
rec.sampleSku = sample.sku;
const explicit = hasTag(tags, 'quote-only') || hasTag(tags, 'contact-for-price');
if (explicit) plan.tranche1.push({ ...rec, intent: 'explicit quote-only' });
else if (hasTag(tags, 'needs-price'))
plan.tranche2.push({ ...rec, intent: 'Needs-Price only',
incompleteImport: hasTag(tags, 'needs-width') && hasTag(tags, 'needs-image') });
else plan.excluded.push({ ...rec, why: 'no quote-intent tag — needs individual review' });
}
}
fs.writeFileSync(path.join(DIR, 'data', 'plan.json'), JSON.stringify(plan, null, 1));
console.log(`tranche1=${plan.tranche1.length} tranche2=${plan.tranche2.length} excluded=${plan.excluded.length}`);
console.log(` tranche2 incomplete-import: ${plan.tranche2.filter(r => r.incompleteImport).length}`);
console.log('wrote data/plan.json — READ-ONLY, nothing mutated.');