← 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.');