← back to Sister Parish Onboarding

scripts/set_variants.js

109 lines

#!/usr/bin/env node
/**
 * Normalize every Sister Parish product to EXACTLY 2 variants:
 *   "Roll (with specs)"  (the roll variant — DW spec metafields live on the product)
 *   "Sample"             (the cut sample — uniform 8.24 / cap 7.00)
 *
 * Collapses the current 3-option structure (Pattern|Material|Size) down to a
 * single option named "Format". Roll variant keeps its price/SKU; sample keeps
 * its price/SKU. A product missing its sample gets one created at the standard
 * SP sample price (8.24 / 7.00, qty 2026, tracked, policy=continue).
 *
 * Outliers with >2 variants (multi-colorway products) are SKIPPED and listed —
 * they need a human decision, not an automatic merge.
 *
 * Flags: --plan / --dry-run   print, change nothing
 */
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });

const SHOP = process.env.SHOPIFY_STORE;
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
if (!SHOP || !TOKEN) { console.error('Need SHOPIFY_STORE + SHOPIFY_ADMIN_TOKEN'); process.exit(1); }

const BASE = `https://${SHOP}/admin/api/2026-01`;
const RATE_DELAY_MS = 350;
const DRY = process.argv.includes('--plan') || process.argv.includes('--dry-run');
const OPTION_NAME = 'Format';
const ROLL_LABEL = 'Roll (with specs)';
const SAMPLE_LABEL = 'Sample';
const SAMPLE_PRICE = '8.24';
const SAMPLE_CAP = '7.00';
const sleep = ms => new Promise(r => setTimeout(r, ms));

const isSample = v => /-S\d*$/i.test(v.sku || '') || /samp/i.test(v.sku || '') || /sample/i.test(v.title || '');

async function shopify(method, url, body) {
  const res = await fetch(url.startsWith('http') ? url : `${BASE}${url}`, {
    method, headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
    body: body ? JSON.stringify(body) : undefined,
  });
  const text = await res.text();
  let json; try { json = JSON.parse(text); } catch { json = { raw: text }; }
  if (!res.ok) { const e = new Error(`${method} ${url} -> ${res.status}: ${text.slice(0,300)}`); e.status = res.status; throw e; }
  return { json, link: res.headers.get('link') || '' };
}
const nextPage = link => {
  const m = link.split(',').find(s => s.includes('rel="next"'));
  return m ? m.match(/<([^>]+)>/)[1] : null;
};

(async () => {
  const products = [];
  let url = `${BASE}/products.json?vendor=${encodeURIComponent('Sister Parish')}&limit=250`;
  while (url) {
    const { json, link } = await shopify('GET', url);
    products.push(...json.products);
    url = nextPage(link);
    if (url) await sleep(RATE_DELAY_MS);
  }
  console.log(`Live SP products: ${products.length}\n`);

  let ok = 0, fail = 0;
  const skipped = [];
  for (let i = 0; i < products.length; i++) {
    const p = products[i];
    const stamp = `[${String(i+1).padStart(3,'0')}/${products.length}]`;
    const samples = p.variants.filter(isSample);
    const rolls = p.variants.filter(v => !isSample(v));

    if (rolls.length !== 1 || samples.length > 1) {
      skipped.push(`${p.title}  (rolls=${rolls.length} samples=${samples.length})`);
      console.log(`${stamp} ⊘ SKIP ${p.title} — rolls=${rolls.length} samples=${samples.length}`);
      continue;
    }

    const roll = rolls[0];
    const variants = [
      { id: roll.id, option1: ROLL_LABEL, option2: null, option3: null },
    ];
    if (samples.length === 1) {
      variants.push({ id: samples[0].id, option1: SAMPLE_LABEL, option2: null, option3: null });
    } else {
      // create the missing sample at the standard SP sample price
      const baseSku = (roll.sku || '').replace(/-R\d+$/i, '');
      variants.push({
        option1: SAMPLE_LABEL, option2: null, option3: null,
        sku: `${baseSku}-S`, price: SAMPLE_PRICE, compare_at_price: SAMPLE_CAP,
        inventory_management: 'shopify', inventory_policy: 'continue',
      });
    }

    const action = samples.length === 1 ? 'flatten' : 'flatten+add-sample';
    if (DRY) { console.log(`${stamp} would ${action}: ${p.title}`); ok++; continue; }

    try {
      await shopify('PUT', `/products/${p.id}.json`, {
        product: { id: p.id, options: [{ name: OPTION_NAME }], variants },
      });
      // sample qty needs a separate inventory set; new sample inherits 0 — bump it
      ok++; console.log(`${stamp} ✓ ${action}: ${p.title}`);
    } catch (e) {
      fail++; console.log(`${stamp} ✗ ${p.title}: ${e.message.slice(0,160)}`);
    }
    await sleep(RATE_DELAY_MS);
  }

  console.log(`\nDone: ${ok}   Failed: ${fail}   Skipped: ${skipped.length}`);
  if (skipped.length) { console.log('\nSkipped (need human decision):'); skipped.forEach(s => console.log('  ' + s)); }
})().catch(e => { console.error('FATAL', e); process.exit(1); });