← back to Designer Wallcoverings

onboarding/graduate-new-2026/create-products.mjs

196 lines

#!/usr/bin/env node
// TK-10908 — Create 7 Graduate Collection SKUs on the LIVE DW Shopify store as DRAFT.
// All DRAFT: no real mfr code sourceable (pre-release designs) → fails go-live gate.
// Settlement: all 3 patterns OK (audited). Vendor "Graduate Collection UK". Roll $205, Sample $4.25.
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';

const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const VER = '2024-10';
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
if (!TOKEN) { console.error('NO TOKEN'); process.exit(1); }
const COLLECTION_GID = 'gid://shopify/Collection/303226519603'; // graduate-collection
const UPLOAD_DIR = '/Users/macstudio3/Projects/Designer-Wallcoverings/onboarding/graduate-new-2026/shopify-upload';
const ROLL_PRICE = '205.00';
const SAMPLE_PRICE = '4.25';
const DRY = process.env.DRY === '1';

const gql = async (query, variables={}) => {
  const r = await fetch(`https://${SHOP}/admin/api/${VER}/graphql.json`, {
    method:'POST',
    headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'},
    body: JSON.stringify({query, variables})
  });
  const j = await r.json();
  if (j.errors) throw new Error('GQL errors: '+JSON.stringify(j.errors));
  return j.data;
};
const sleep = ms => new Promise(r=>setTimeout(r,ms));

const LEOPARD_DESC = `Leopard Stripe by Sophie Tomes is a bold yet elegant wallcovering combining mirrored leopards, soft peach lilies and decorative foliage with wide vertical stripes. Playful animal illustration is balanced by a structured repeat and refined colour palette, giving the design a distinctive maximalist feel that still works beautifully in considered interiors. Designed to bring height, movement and personality to a space, Leopard Stripe works particularly well in hallways, dining rooms, bedrooms, cloakrooms, home bars and feature walls, pairing beautifully with natural wood, painted furniture, warm neutrals, aged brass and complementary botanical tones. Designed by Sophie Tomes and made in the UK, Leopard Stripe reflects Graduate Collection's designer-led approach, combining original illustration, expressive colour and practical paste-the-wall quality.`;

const CHEETAH_DESC = `Cheetah Palm by Sophie Tomes is a richly illustrated tropical wallcovering combining prowling spotted big cats with oversized monstera leaves, fan palms, delicate orchids and layers of lush botanical foliage. The large-scale pattern creates depth and movement across the wall, balancing exotic animal imagery with elegant botanical detail. Across every colourway the combination of tropical planting, orchids and the spotted cat gives Cheetah Palm a luxurious maximalist quality without losing the refinement of the original hand-drawn artwork. The collection works particularly well in dining rooms, bedrooms, hallways, cloakrooms, home bars and statement feature areas, and pairs beautifully with natural wood, rattan, aged brass, painted furniture, warm neutrals and complementary botanical tones. Designed by Sophie Tomes and made in the UK, the collection reflects Graduate Collection's designer-led approach, combining original illustration, expressive colour and practical paste-the-wall quality.`;

const COWGIRL_DESC = `Cowgirl by Sophie Tomes is a playful, character-rich wallcovering design that brings a contemporary decorative feel to classic Western imagery. Elegant cowgirls, horses, cowboy boots, cacti, wildflowers and a longhorn skull are illustrated across a softly textured cream ground, creating a pattern full of movement, storytelling and personality. The warm palette of rust, sage green, blush pink, powder blue and rich brown gives Cowgirl a lively yet considered feel, with a neutral background that keeps the design balanced and versatile while the hand-drawn motifs add charm and individuality. Cowgirl works beautifully in spaces where a strong decorative statement is wanted, from cloakrooms, bedrooms and dressing rooms to hallways, home bars and feature walls, pairing particularly well with natural timber, aged leather, brass, terracotta, painted joinery and warm neutral tones. Designed by Sophie Tomes and made in the UK, Cowgirl reflects Graduate Collection's designer-led approach, combining expressive original artwork, sophisticated colour and practical paste-the-wall quality.`;

// specs
const SPECS = {
  leopard:  { repeat:'72.5cm / 28.54in',  match:'Straight',  desc:LEOPARD_DESC, pattern:'Leopard Stripe', style:'animal, floral' },
  cheetah:  { repeat:'72.25cm / 28.44in', match:'Straight',  desc:CHEETAH_DESC, pattern:'Cheetah Palm', style:'tropical, animal, floral' },
  cowgirl:  { repeat:'75cm / 29.53in',    match:'Half drop', desc:COWGIRL_DESC, pattern:'Cowgirl', style:'novelty, illustrative' },
};
const COMMON = { width:'52cm / 20.47in', roll:'10m / 10.94yd', material:'Non-woven', application:'Paste the wall', designer:'Sophie Tomes', origin:'United Kingdom' };

const PRODUCTS = [
  { dw:'GRD-990008', pat:'leopard', color:'Green',    title:'Leopard Stripe Green | Graduate Collection',    img:'GRD-leopard-stripe-green-01.jpg',    room:'GRD-leopard-stripe-green-02-room.jpg' },
  { dw:'GRD-990009', pat:'leopard', color:'Blue',     title:'Leopard Stripe Blue | Graduate Collection',     img:'GRD-leopard-stripe-blue-01.jpg',     room:'GRD-leopard-stripe-blue-02-room.jpg' },
  { dw:'GRD-990010', pat:'leopard', color:'Yellow',   title:'Leopard Stripe Yellow | Graduate Collection',   img:'GRD-leopard-stripe-yellow-01.jpg',   room:'GRD-leopard-stripe-yellow-02-room.jpg' },
  { dw:'GRD-990011', pat:'cheetah', color:'Cream',    title:'Cheetah Palm Cream | Graduate Collection',      img:'GRD-cheetah-palm-cream-01.jpg',      room:'GRD-cheetah-palm-cream-02-room.jpg' },
  { dw:'GRD-990012', pat:'cheetah', color:'Teal',     title:'Cheetah Palm Teal | Graduate Collection',       img:'GRD-cheetah-palm-teal-01.jpg',       room:'GRD-cheetah-palm-teal-02-room.jpg' },
  { dw:'GRD-990013', pat:'cheetah', color:'Charcoal', title:'Cheetah Palm Charcoal | Graduate Collection',   img:'GRD-cheetah-palm-charcoal-01.jpg',   room:'GRD-cheetah-palm-charcoal-02-room.jpg' },
  { dw:'GRD-990014', pat:'cowgirl', color:'',         title:'Cowgirl | Graduate Collection',                 img:'GRD-cowgirl-01.jpg',                 room:'GRD-cowgirl-02-room.jpg' },
];

// Staged image upload: create staged target, PUT file, then attach via productCreateMedia
async function uploadImage(productGid, filePath, altText) {
  const filename = path.basename(filePath);
  const size = fs.statSync(filePath).size.toString();
  const staged = await gql(`mutation($input:[StagedUploadInput!]!){ stagedUploadsCreate(input:$input){ stagedTargets{ url resourceUrl parameters{name value} } userErrors{field message} } }`,
    { input:[{ filename, mimeType:'image/jpeg', httpMethod:'POST', resource:'IMAGE', fileSize:size }] });
  const t = staged.stagedUploadsCreate.stagedTargets[0];
  if (!t) throw new Error('no staged target: '+JSON.stringify(staged.stagedUploadsCreate.userErrors));
  // POST multipart to the staged URL
  const form = new FormData();
  for (const p of t.parameters) form.append(p.name, p.value);
  const buf = fs.readFileSync(filePath);
  form.append('file', new Blob([buf], {type:'image/jpeg'}), filename);
  const up = await fetch(t.url, { method:'POST', body: form });
  if (!(up.status>=200 && up.status<300)) throw new Error('staged upload failed '+up.status+' '+(await up.text()).slice(0,200));
  // attach
  const media = await gql(`mutation($id:ID!,$media:[CreateMediaInput!]!){ productCreateMedia(productId:$id, media:$media){ media{ id status } mediaUserErrors{field message} } }`,
    { id:productGid, media:[{ originalSource: t.resourceUrl, alt: altText, mediaContentType:'IMAGE' }] });
  const errs = media.productCreateMedia.mediaUserErrors;
  if (errs && errs.length) throw new Error('media attach errors: '+JSON.stringify(errs));
  return media.productCreateMedia.media[0];
}

const results = [];
const TODO = process.env.ONLYFIRST === '1' ? PRODUCTS.slice(0,1)
           : process.env.SKIP_DONE ? PRODUCTS.filter(p=>!process.env.SKIP_DONE.split(',').includes(p.dw))
           : PRODUCTS;
for (const p of TODO) {
  const s = SPECS[p.pat];
  const displayColor = p.color || '';
  const patName = s.pattern + (displayColor ? ' '+displayColor : '');
  console.log(`\n=== ${p.dw} :: ${p.title} ===`);
  if (DRY) { results.push({...p, status:'DRY'}); continue; }
  try {
    const tags = ['Graduate Collection','Sophie Tomes','Wallcovering','Made in UK','Needs-MFR-Code', s.pattern].join(', ');
    // 1. productCreate (DRAFT) with body + tags + vendor + type
    const pc = await gql(`mutation($input:ProductInput!){ productCreate(input:$input){ product{ id handle } userErrors{field message} } }`, {
      input: {
        title: p.title,
        vendor: 'Graduate Collection UK',
        productType: 'Wallcovering',
        status: 'DRAFT',
        tags,
        descriptionHtml: '<p>'+s.desc+'</p>',
      }
    });
    if (pc.productCreate.userErrors.length) throw new Error(JSON.stringify(pc.productCreate.userErrors));
    const gid = pc.productCreate.product.id;
    const handle = pc.productCreate.product.handle;
    console.log('  created', gid, handle);
    await sleep(600);

    // 2. Rename default variant → ROLL (price+sku+option value) FIRST, then create Sample.
    const pv = await gql(`{ product(id:"${gid}"){ variants(first:5){edges{node{id title inventoryItem{id}}}} options{id name} }}`);
    const defVar = pv.product.variants.edges[0].node;
    const optName = pv.product.options[0].name; // "Title"
    const rollTitle = 'Sold Per Roll - 20.5" wide x 11-yds';
    const u1 = await gql(`mutation($productId:ID!,$variants:[ProductVariantsBulkInput!]!){ productVariantsBulkUpdate(productId:$productId, variants:$variants){ productVariants{id sku title} userErrors{field message} } }`, {
      productId: gid,
      variants: [{ id: defVar.id, price: ROLL_PRICE, inventoryItem:{ sku: p.dw, tracked:false }, optionValues:[{ optionName: optName, name: rollTitle }] }]
    });
    if (u1.productVariantsBulkUpdate.userErrors.length) console.log('  roll-variant WARN:', JSON.stringify(u1.productVariantsBulkUpdate.userErrors));
    await sleep(500);
    // Add Sample variant (roll already named, so no merge)
    const bc = await gql(`mutation($productId:ID!,$variants:[ProductVariantsBulkInput!]!){ productVariantsBulkCreate(productId:$productId, variants:$variants){ productVariants{id title sku} userErrors{field message} } }`, {
      productId: gid,
      variants: [{ price: SAMPLE_PRICE, inventoryItem:{ sku: p.dw+'-sample', tracked:false }, optionValues:[{ optionName: optName, name:'Sample' }] }]
    });
    if (bc.productVariantsBulkCreate.userErrors.length) console.log('  sample-variant WARN:', JSON.stringify(bc.productVariantsBulkCreate.userErrors));
    await sleep(500);

    // 3. Metafields (width/repeat/match/material/application + specs)
    // NOTE metafieldsSet is ATOMIC — a single bad type rolls back the whole batch.
    // Verified types: custom.width/custom.repeat/custom.pattern_repeat/specs.type/specs.pattern_repeat = single_line_text_field;
    // custom.material = multi_line_text_field; custom.match & custom.type = product_reference (CANNOT hold text → skip).
    // global.width/global.repeat/global.unit_of_measure = app-reserved single_line_text (writable via metafieldsSet, verified live on Tiger Tiger).
    const mfs = [
      { namespace:'global', key:'width', type:'single_line_text_field', value: COMMON.width },
      { namespace:'custom', key:'width', type:'single_line_text_field', value: COMMON.width },
      { namespace:'global', key:'repeat', type:'single_line_text_field', value: s.repeat },
      { namespace:'custom', key:'repeat', type:'single_line_text_field', value: s.repeat },
      { namespace:'custom', key:'pattern_repeat', type:'single_line_text_field', value: s.repeat },
      { namespace:'specs', key:'pattern_repeat', type:'single_line_text_field', value: s.repeat },
      { namespace:'specs', key:'pattern_match', type:'single_line_text_field', value: s.match },
      { namespace:'custom', key:'material', type:'multi_line_text_field', value: COMMON.material },
      { namespace:'specs', key:'type', type:'single_line_text_field', value: COMMON.application+' wallcovering (Non-woven)' },
      { namespace:'custom', key:'pattern_name', type:'single_line_text_field', value: s.pattern },
      { namespace:'specs', key:'style', type:'single_line_text_field', value: s.style },
      { namespace:'global', key:'unit_of_measure', type:'single_line_text_field', value:'Full Roll' },
      { namespace:'specs', key:'width', type:'single_line_text_field', value: COMMON.width },
      { namespace:'specs', key:'match', type:'single_line_text_field', value: s.match },
      { namespace:'specs', key:'material', type:'single_line_text_field', value: COMMON.material },
      { namespace:'specs', key:'application', type:'single_line_text_field', value: COMMON.application },
      { namespace:'specs', key:'roll_length', type:'single_line_text_field', value: COMMON.roll },
      { namespace:'custom', key:'designer', type:'single_line_text_field', value: COMMON.designer },
    ];
    const msres = await gql(`mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ metafields{namespace key} userErrors{field message} } }`, {
      mf: mfs.map(m=>({ ownerId:gid, namespace:m.namespace, key:m.key, type:m.type, value:m.value }))
    });
    if (msres.metafieldsSet.userErrors.length) console.log('  metafield WARN:', JSON.stringify(msres.metafieldsSet.userErrors));
    else console.log('  metafields set:', msres.metafieldsSet.metafields.length);
    await sleep(400);

    // 4. Images (flat design first = featured, then room set)
    const flat = path.join(UPLOAD_DIR, p.img);
    const room = path.join(UPLOAD_DIR, p.room);
    let imgOk=false;
    try { await uploadImage(gid, flat, patName+' Wallcovering by Graduate Collection'); imgOk=true; await sleep(700);
          await uploadImage(gid, room, patName+' room setting'); await sleep(700); }
    catch(e){ console.log('  IMAGE WARN:', e.message); }

    // 5. Collection membership is AUTOMATIC: graduate-collection is a SMART collection
    //    (rule VENDOR EQUALS "Graduate Collection UK"), so setting the vendor already puts
    //    the product in it. No manual add needed (collectionAddProducts fails on smart collections).

    const numId = gid.split('/').pop();
    const url = `https://admin.shopify.com/store/designer-laboratory-sandbox/products/${numId}`;
    console.log('  DONE', p.dw, '→', url, imgOk?'(2 imgs)':'(IMG WARN)');
    results.push({ ...p, gid, handle, url, imgOk, status:'DRAFT' });
    // 6. Ledger (JSON on stdin)
    try {
      execSync(`node ${process.env.HOME}/.claude/yolo-queue/executed-reversible/log-exec.mjs`, {
        input: JSON.stringify({
          ts:new Date().toISOString(), agent:'vp-dw-commerce', ticket:'TK-10908',
          action:`Created DRAFT Shopify product ${p.dw} "${p.title}" (Graduate Collection UK, roll $${ROLL_PRICE}, sample $${SAMPLE_PRICE}, ${imgOk?'2 imgs':'no imgs'}, in graduate-collection)`,
          blast_radius:1, undo_cmd:`Shopify productDelete ${gid} (or productUpdate status:ARCHIVED)`,
          verify:`GET ${url}`
        }), stdio:['pipe','pipe','pipe']
      });
      console.log('  ledgered');
    } catch(e){ console.log('  ledger WARN:', e.message.split('\n')[0]); }
    await sleep(1500); // rate-limit safety
  } catch(e) {
    console.log('  ERROR', p.dw, e.message);
    results.push({ ...p, status:'ERROR', error:e.message });
  }
}

fs.writeFileSync('/Users/macstudio3/Projects/Designer-Wallcoverings/onboarding/graduate-new-2026/results.json', JSON.stringify(results,null,2));
console.log('\n=== SUMMARY ===');
for (const r of results) console.log(r.dw, r.status, r.url||'', r.error||'');