← back to Majilite Onboard
scripts/publish_shopify.js
174 lines
#!/usr/bin/env node
/**
* Publish Majilite Metallic Specialties I as $4.25 SAMPLE products on the live
* DW Shopify store (designer-laboratory-sandbox = production). Mirrors the DW
* house structure: one "Sample" variant @ $4.25, dwc + custom spec metafields,
* display_variant tag, local swatch image via staged upload.
*
* Idempotent: skips a SKU whose "<dw_sku>-Sample" variant already exists.
* PG-first-then-Shopify: majilite_catalog is already staged; this creates the
* authoritative Shopify product and records the id back to data/published.json.
*
* Usage:
* node scripts/publish_shopify.js --canary 1 # first N only
* node scripts/publish_shopify.js --from 2 --to 160 # range
* node scripts/publish_shopify.js --all # everything (skips done)
* node scripts/publish_shopify.js --status draft # default: active
* node scripts/publish_shopify.js --dry # no writes, plan only
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const API = '2024-10';
const STORE = process.env.SHOPIFY_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');
const m = env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m); return m ? m[1].replace(/["']/g, '').trim() : '';
})();
const GQL = `https://${STORE}/admin/api/${API}/graphql.json`;
const args = process.argv.slice(2);
const opt = (k, d) => { const i = args.indexOf('--' + k); return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true) : d; };
const DRY = !!opt('dry', false);
const STATUS = (opt('status', 'active') === 'draft') ? 'DRAFT' : 'ACTIVE';
const products = JSON.parse(fs.readFileSync(path.join(ROOT, 'data/products.json'), 'utf8'));
let range;
if (opt('canary')) range = products.slice(0, Number(opt('canary')));
else if (opt('all')) range = products;
else { const f = Number(opt('from', 1)), t = Number(opt('to', products.length)); range = products.filter(p => p.seq >= f && p.seq <= t); }
const PUB_PATH = path.join(ROOT, 'data/published.json');
const published = fs.existsSync(PUB_PATH) ? JSON.parse(fs.readFileSync(PUB_PATH, 'utf8')) : {};
const savePub = () => fs.writeFileSync(PUB_PATH, JSON.stringify(published, null, 2));
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function gql(query, variables) {
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch(GQL, { 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) {
const throttled = JSON.stringify(j.errors).includes('THROTTLED');
if (throttled) { await sleep(1500 * (attempt + 1)); continue; }
throw new Error('GQL errors: ' + JSON.stringify(j.errors).slice(0, 300));
}
return j.data;
}
throw new Error('GQL throttled after retries');
}
const slugify = s => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
async function existingVariant(sku) {
const d = await gql(`query($q:String!){ productVariants(first:1, query:$q){ nodes{ id product{ id handle status } } } }`, { q: `sku:${sku}` });
return d.productVariants.nodes[0] || null;
}
async function uploadImage(filePath, filename) {
const d = await gql(`mutation($input:[StagedUploadInput!]!){ stagedUploadsCreate(input:$input){ stagedTargets{ url resourceUrl parameters{ name value } } userErrors{ field message } } }`,
{ input: [{ filename, mimeType: 'image/png', resource: 'IMAGE', httpMethod: 'POST' }] });
const err = d.stagedUploadsCreate.userErrors; if (err && err.length) throw new Error('stagedUpload: ' + JSON.stringify(err));
const t = d.stagedUploadsCreate.stagedTargets[0];
const form = new FormData();
for (const p of t.parameters) form.append(p.name, p.value);
form.append('file', new Blob([fs.readFileSync(filePath)], { type: 'image/png' }), filename);
const up = await fetch(t.url, { method: 'POST', body: form });
if (!(up.status >= 200 && up.status < 300)) throw new Error('staged POST ' + up.status);
return t.resourceUrl;
}
function metafields(p) {
const S = (ns, key, value, type = 'single_line_text_field') => ({ namespace: ns, key, type, value: String(value) });
// Only keys proven to exist as single_line_text_field on this store (from a live
// DWMA product). Full spec (weight/thickness/durability/FR) lives in descriptionHtml
// to avoid colliding with typed metafield definitions (e.g. custom.weight = weight type).
return [
S('dwc', 'brand', p.vendor), S('dwc', 'pattern_name', p.pattern), S('dwc', 'color', p.color),
S('dwc', 'collection', p.collection), S('dwc', 'manufacturer_sku', p.mfr_sku), S('dwc', 'width', p.spec.width),
S('custom', 'brand', p.vendor), S('custom', 'pattern_name', p.pattern), S('custom', 'color', p.color),
S('custom', 'collection_name', p.collection), S('custom', 'manufacturer_sku', p.mfr_sku),
S('custom', 'width', p.spec.width), S('custom', 'color_hex', p.color_hex),
S('custom', 'product_class', 'Wallcovering'), S('custom', 'material_category', 'Faux Leather (Nytek)'),
];
}
function bodyHtml(p) {
const s = p.spec;
return `<p><strong>${p.pattern} — ${p.color}</strong>. ${p.vendor} ${p.brand}® faux leather from the ${p.collection} collection — a specially engineered Nylon Fiber Matrix, luxurious, breathable and durable, ideal for wall coverings, seating, flat surfaces and displays.</p>`
+ `<ul><li>Width: ${s.width}</li><li>Weight: ${s.weight}</li><li>Thickness: ${s.thickness}</li>`
+ `<li>Durability: ${s.durability}</li><li>Flammability: ${s.flammability_inherent}</li>`
+ `<li>Cleanability: ${s.cleanability_code} · Crocking ${s.crocking}</li>`
+ `<li>PFAS-, PVC- and plasticizer-free · no off-gassing</li></ul>`
+ `<p><em>Available by the yard (54" wide) at $${p.pricing.retail}/yd, or order a $4.25 sample swatch.</em></p>`;
}
async function publishOne(p) {
const num = p.dw_sku.replace('DWCC-', '');
const oldKey = `DWMJ-${num}`; // key under which it was first published (153 of these)
const newKey = p.dw_sku; // DWCC-600xxx
const sampleSku = `${p.dw_sku}-Sample`;
const yardSku = p.dw_sku;
if (published[newKey] && published[newKey].reconciled) return { skip: 'done' };
const prior = published[oldKey]; // existing live DWMJ product, if any
const mode = (prior && prior.productId) ? 'update' : 'create';
if (DRY) return { dry: true, mode, title: p.title };
const baseHandle = `majilite-${slugify(p.pattern + '-' + p.color)}`;
const tags = Array.from(new Set([...p.tags, p.dw_sku, 'display_variant', 'Sample', 'By The Yard']));
const buildInput = (handle) => ({
...(mode === 'update' ? { id: prior.productId } : {}),
handle, title: p.title, vendor: p.vendor, productType: p.product_type, status: STATUS,
descriptionHtml: bodyHtml(p), tags,
productOptions: [{ name: 'Type', values: [{ name: 'Sample' }, { name: 'Per Yard' }] }],
variants: [
{ optionValues: [{ optionName: 'Type', name: 'Sample' }], price: '4.25', sku: sampleSku, inventoryPolicy: 'CONTINUE', taxable: true },
{ optionValues: [{ optionName: 'Type', name: 'Per Yard' }], price: String(p.pricing.retail), sku: yardSku, inventoryPolicy: 'CONTINUE', taxable: true },
],
metafields: metafields(p),
});
const run = async (handle) => {
const d = await gql(`mutation($input:ProductSetInput!){ productSet(synchronous:true, input:$input){ product{ id handle status } userErrors{ field message } } }`, { input: buildInput(handle) });
return d.productSet;
};
let ps = await run(baseHandle);
if (ps.userErrors && ps.userErrors.length) {
const collision = JSON.stringify(ps.userErrors).toLowerCase().includes('handle');
if (mode === 'create' && collision) ps = await run(`${baseHandle}-${num}`); // collision-safe create
if (ps.userErrors && ps.userErrors.length) throw new Error('productSet: ' + JSON.stringify(ps.userErrors));
}
const prod = ps.product;
// image: creates get a fresh swatch; updates keep the swatch they already have
let media = 'kept';
if (mode === 'create') {
media = 'none';
try {
const resourceUrl = await uploadImage(path.join(ROOT, 'public', p.image), `${p.dw_sku}.png`);
const m = await gql(`mutation($id:ID!,$media:[CreateMediaInput!]!){ productCreateMedia(productId:$id, media:$media){ media{ status } mediaUserErrors{ field message } } }`,
{ id: prod.id, media: [{ originalSource: resourceUrl, alt: `${p.title} — ${p.vendor} ${p.brand}`, mediaContentType: 'IMAGE' }] });
const me = m.productCreateMedia.mediaUserErrors; media = (me && me.length) ? 'err:' + JSON.stringify(me) : 'ok';
} catch (e) { media = 'img-fail:' + e.message.slice(0, 80); }
}
published[newKey] = { productId: prod.id, handle: prod.handle, status: prod.status, sample_sku: sampleSku, yard_sku: yardSku, retail: p.pricing.retail, mode, from: (mode === 'update' ? oldKey : 'new'), image: media, reconciled: true, at: new Date().toISOString() };
savePub();
return { [mode === 'update' ? 'updated' : 'created']: prod.id, handle: prod.handle, image: media, mode };
}
(async () => {
console.log(`Publish → ${STORE} status=${STATUS} ${DRY ? '(DRY)' : ''} targets=${range.length}`);
let created = 0, skipped = 0, failed = 0;
for (const p of range) {
try {
const r = await publishOne(p);
if (r.skip) { skipped++; process.stdout.write(`· ${p.dw_sku} skip(${r.skip})\n`); }
else if (r.dry) { process.stdout.write(`plan ${p.dw_sku} [${r.mode}] ${p.title}\n`); }
else { created++; const verb = r.updated ? '↑upd' : '✓new'; process.stdout.write(`${verb} ${p.dw_sku} ${p.title} → ${r.handle} [img:${r.image}] (${created})\n`); }
} catch (e) { failed++; process.stdout.write(`✗ ${p.dw_sku} ${p.title} — ${e.message.slice(0, 160)}\n`); }
await sleep(350);
}
console.log(`\nDone. created=${created} skipped=${skipped} failed=${failed} | published.json has ${Object.keys(published).length} records`);
})();