← back to Flock Fix Viewer

priceup-flock.mjs

100 lines

// Price up 27"-wide flock products into 2-variant (roll $212.99 + sample $4.25) products,
// modeled on Naya's Zodiac. Snapshot + ledger each. Modes: --dry | --pilot | --apply
import fs from 'fs'; import os from 'os'; import path from 'path';
const SHOP='designer-laboratory-sandbox.myshopify.com';
const TOKEN=fs.readFileSync(new URL('./.token',import.meta.url),'utf8').trim();
const LEDGER=path.join(os.homedir(),'.claude/yolo-queue/executed-reversible/ledger.jsonl');
const SNAPDIR=new URL('./priceup-snapshots/',import.meta.url);
const MODE=process.argv.includes('--apply')?'apply':(process.argv.includes('--pilot')?'pilot':'dry');
const ROLL_OPT='Sold per single roll (27" x 5.5 yards)';
const ROLL_PRICE='212.99', SAMPLE_PRICE='4.25';
try{fs.mkdirSync(SNAPDIR,{recursive:true});}catch(e){}

async function gql(query,variables={}){
  const r=await fetch(`https://${SHOP}/admin/api/2024-10/graphql.json`,{method:'POST',
    headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'},body:JSON.stringify({query,variables})});
  return r.json();
}
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
const Q=`query($c:String){ products(first:100, query:"(tag:'Flock Velvet' OR title:flock OR handle:flock)", after:$c){
  pageInfo{hasNextPage endCursor}
  edges{node{ id handle title status
    options{name values}
    variants(first:10){edges{node{id sku title price selectedOptions{name value}}}}
    wGlobal: metafield(namespace:"global",key:"width"){value}
    wCustom: metafield(namespace:"custom",key:"width"){value}
  }}
}}`;
const norm=t=>(t||'').toLowerCase().replace(/\s*\|.*$/,'').replace(/memo sample|flocked velvet|flock velvet|wallcovering|wallpaper|\bmemo\b|\bsample\b/g,'').replace(/[^a-z0-9 ]/g,' ').replace(/\s+/g,' ').trim();
function widthInches(n){
  const w=(n.wGlobal&&n.wGlobal.value)||(n.wCustom&&n.wCustom.value)||'';
  const m=String(w).match(/([\d.]+)\s*inch/i); if(m) return parseFloat(m[1]);
  for(const e of n.variants.edges){const mm=(e.node.title||'').match(/\(([\d.]+)"/); if(mm) return parseFloat(mm[1]);}
  return null;
}
function maxRoll(n){return n.variants.edges.map(e=>e.node).filter(v=>!/sample/i.test(v.title||'')).reduce((m,v)=>Math.max(m,parseFloat(v.price)||0),0);}

const SET=`mutation($input:ProductSetInput!){ productSet(synchronous:true, input:$input){ product{id handle
  variants(first:10){edges{node{sku title price}}}} userErrors{field message} } }`;

function buildInput(n, rollSku, sampleSku){
  return { id:n.id,
    productType:'Wallcovering',
    productOptions:[{name:'Size', values:[{name:ROLL_OPT},{name:'Sample'}]}],
    variants:[
      {optionValues:[{optionName:'Size',name:ROLL_OPT}], price:ROLL_PRICE, sku:rollSku, inventoryPolicy:'CONTINUE'},
      {optionValues:[{optionName:'Size',name:'Sample'}], price:SAMPLE_PRICE, sku:sampleSku, inventoryPolicy:'CONTINUE'}
    ],
    metafields:[
      {namespace:'global',key:'v_prods_quantity_order_min',type:'single_line_text_field',value:'2'},
      {namespace:'global',key:'v_prods_quantity_order_units',type:'single_line_text_field',value:'2'},
      {namespace:'global',key:'unit_of_measure',type:'single_line_text_field',value:'Priced Per Single Roll. Packaged in Double Rolls.'},
      {namespace:'global',key:'width',type:'single_line_text_field',value:'27 Inches Wide'},
      {namespace:'custom',key:'width',type:'single_line_text_field',value:'27 Inches Wide'},
      {namespace:'global',key:'length',type:'single_line_text_field',value:'5.5 Yards'}
    ]
  };
}

(async()=>{
  let cur=null,all=[];
  do{const r=await gql(Q,{c:cur});const p=r?.data?.products;if(!p){console.error(JSON.stringify(r));break;}all=all.concat(p.edges);cur=p.pageInfo.hasNextPage?p.pageInfo.endCursor:null;}while(cur);
  const nodes=all.map(e=>e.node);
  // width map from priced twins (pattern -> inches)
  const twinWidth={};
  for(const n of nodes){ if(maxRoll(n)>10){ const w=widthInches(n); if(w) twinWidth[norm(n.title)]=w; } }
  // targets: sample-only (no roll price) AND 27" wide (own width metafield/option OR twin)
  const ACTIVE_ONLY = !process.argv.includes('--include-draft');
  const targets=nodes.filter(n=>{
    if(n.status==='ARCHIVED') return false;     // never touch archived
    if(ACTIVE_ONLY && n.status!=='ACTIVE') return false; // ACTIVE only unless --include-draft
    if(maxRoll(n)>10) return false;            // already priced -> skip
    const w=widthInches(n) ?? twinWidth[norm(n.title)] ?? null;
    return w===27;                              // 27" only; unknown width -> skipped
  });
  const skippedUnknown=nodes.filter(n=>maxRoll(n)<=10 && (widthInches(n)??twinWidth[norm(n.title)]??null)===null);
  console.log(`flock=${nodes.length} · 27\" sample-only TARGETS=${targets.length} · skipped(width unknown)=${skippedUnknown.length}`);
  if(MODE==='dry'){ targets.slice(0,15).forEach(n=>console.log('  target',n.status,n.handle)); return; }

  const list = MODE==='pilot' ? targets.slice(0,1) : targets;
  console.log(`${MODE.toUpperCase()} on ${list.length} product(s)`);
  let done=0,failed=0;
  for(const n of list){
    fs.writeFileSync(new URL(n.handle.replace(/[^a-z0-9-]/gi,'_')+'.json',SNAPDIR), JSON.stringify(n,null,2));
    const base=(n.variants.edges.map(e=>e.node).find(v=>v.sku)?.sku||n.handle).replace(/-sample$/i,'').replace(/-Sample$/,'');
    const rollSku=base, sampleSku=base+'-Sample';
    const r=await gql(SET,{input:buildInput(n,rollSku,sampleSku)});
    const errs=r?.data?.productSet?.userErrors;
    if(errs&&errs.length){failed++;console.log('  FAIL',n.handle,JSON.stringify(errs));continue;}
    done++;
    const vs=r.data.productSet.product.variants.edges.map(e=>e.node);
    console.log('  OK',n.handle,'->',vs.map(v=>v.title+'=$'+v.price).join(' | '));
    fs.appendFileSync(LEDGER,JSON.stringify({ts:new Date().toISOString(),agent:'main:priceup-flock',ticket:'flock-priceup-27',
      action:`price up ${n.handle} to roll $212.99 + sample $4.25 (27" flock)`,blast_radius:1,
      undo_cmd:`restore from priceup-snapshots/${n.handle}.json (prior variants/options)`,
      verify:`product ${n.handle} has 2 variants roll $212.99 + sample $4.25`})+'\n');
    await sleep(400);
  }
  console.log(`${MODE} DONE · ok=${done} failed=${failed}`);
})();