[object Object]

← back to Designer Wallcoverings

Variant buttons v2: Sample button guaranteed on every SKU (JSON fallback for malformed single-variant SKUs) + buy-box placement; drift-safe replace-on-rerun push to dev theme

a048effe0287096cfc3f395ac7a92b5dcde0b18c · 2026-06-21 19:23:28 -0700 · Steve Abrams

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit a048effe0287096cfc3f395ac7a92b5dcde0b18c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Jun 21 19:23:28 2026 -0700

    Variant buttons v2: Sample button guaranteed on every SKU (JSON fallback for malformed single-variant SKUs) + buy-box placement; drift-safe replace-on-rerun push to dev theme
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 shopify/push-variant-ux-to-dev-theme.sh | 170 ++++++++++++++++++--------------
 1 file changed, 94 insertions(+), 76 deletions(-)

diff --git a/shopify/push-variant-ux-to-dev-theme.sh b/shopify/push-variant-ux-to-dev-theme.sh
index 45d3f980..cfa89b1d 100755
--- a/shopify/push-variant-ux-to-dev-theme.sh
+++ b/shopify/push-variant-ux-to-dev-theme.sh
@@ -1,14 +1,14 @@
 #!/usr/bin/env bash
-# Apply the latest UX (variant buttons + price-restore hardening) to the Shopify
-# DEV theme "Updated copy of NewWall Template [Dev]" — drift-safe.
+# Apply the latest UX (variant buttons v2 + price-restore hardening) to the
+# Shopify DEV theme "Updated copy of NewWall Template [Dev]" — drift-safe.
 #
-# Instead of overwriting theme.liquid with a local copy, this PULLS the dev
-# theme's current layout/theme.liquid, applies ONLY the two surgical edits to it,
-# backs up the original, and pushes the result back. Never touches the live/main
-# theme.
+# PULLS the dev theme's current layout/theme.liquid, REPLACES any prior injected
+# DW variant-button block with the current one (so re-runs update in place),
+# hardens the price-restore selectors if the dead one is present, backs up the
+# original, pushes the result, verifies. Never touches the live/main theme.
 #
-# Needs a theme-scoped token (Shopify Admin → Apps → "Theme Access" → Add password):
-#   THEME_TOKEN=shptka_xxx bash ~/pd.sh
+# Needs a theme-scoped token:
+#   THEME_TOKEN=shpat_xxx bash ~/pd.sh
 set -euo pipefail
 cd "$(dirname "$0")"
 
@@ -18,46 +18,38 @@ SECRETS="$HOME/Projects/secrets-manager/.env"
 TARGET_NAME="Updated copy of NewWall Template [Dev]"
 
 TOK="${THEME_TOKEN:-$(grep -hE '^SHOPIFY_THEME_TOKEN=' "$SECRETS" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"'"'"' ')}"
-[ -z "$TOK" ] && { echo "No theme token. Create a Shopify 'Theme Access' password and run:  THEME_TOKEN=shptka_xxx bash ~/pd.sh"; exit 1; }
+[ -z "$TOK" ] && { echo "No theme token. Run:  THEME_TOKEN=shpat_xxx bash ~/pd.sh"; exit 1; }
 echo "token …${TOK: -4}"
 
 mkdir -p theme-backups
 python3 - "$STORE" "$API" "$TOK" "$TARGET_NAME" "theme-backups" <<'PY'
-import sys, json, urllib.request, urllib.parse, datetime
+import sys, json, re, urllib.request, urllib.parse, datetime
 store, api, tok, target_name, bkdir = sys.argv[1:6]
 H = {"X-Shopify-Access-Token": tok, "Content-Type": "application/json"}
+def get(url): return json.load(urllib.request.urlopen(urllib.request.Request(url, headers=H)))
 
-def get(url):
-    return json.load(urllib.request.urlopen(urllib.request.Request(url, headers=H)))
-
-# 1. find the dev theme by exact name (fallback: role=development), never main
 try:
     themes = get(f"https://{store}/admin/api/{api}/themes.json")["themes"]
 except urllib.error.HTTPError as e:
     print("THEMES LIST FAILED HTTP", e.code, e.read().decode()[:300]); sys.exit(1)
 print("themes:")
 for t in themes: print("  ", t["id"], t["role"], "|", t["name"])
-match = [t for t in themes if t["name"] == target_name]
-if not match: match = [t for t in themes if t["role"] == "development"]
+match = [t for t in themes if t["name"] == target_name] or [t for t in themes if t["role"]=="development"]
 match = [t for t in match if t["role"] != "main"]
-if not match:
-    print(f'Target theme "{target_name}" not found among non-main themes.'); sys.exit(1)
+if not match: print(f'Target theme "{target_name}" not found.'); sys.exit(1)
 theme = match[0]; tid = theme["id"]
 print(f'=== target: {tid} "{theme["name"]}" (role={theme["role"]}) — live untouched ===')
 
-# 2. pull current theme.liquid
 key = "layout/theme.liquid"
 q = urllib.parse.urlencode({"asset[key]": key})
 cur = get(f"https://{store}/admin/api/{api}/themes/{tid}/assets.json?{q}")["asset"]["value"]
 stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
 bk = f"{bkdir}/theme.liquid.dev-{tid}.{stamp}.bak"
-open(bk, "w").write(cur)
-print(f"backup: {bk} ({len(cur)} bytes)")
+open(bk, "w").write(cur); print(f"backup: {bk} ({len(cur)} bytes)")
 
-src = cur
-changes = []
+src = cur; changes = []
 
-# 3a. price-restore selector hardening (only if the dead selector is present)
+# price-restore hardening (only if the dead selector is present)
 old_restore = ('#shopify-section-product [class*="price"],\n'
                '#shopify-section-product span.money,')
 new_restore = ('[id^="shopify-section-template"][id$="__main"] .product-price-minimum,\n'
@@ -65,14 +57,12 @@ new_restore = ('[id^="shopify-section-template"][id$="__main"] .product-price-mi
                '[id^="shopify-section-template"][id$="__main"] span.money,\n'
                '.product__price .money,')
 if "shopify-section-template" not in src and old_restore in src:
-    src = src.replace(old_restore, new_restore, 1); changes.append("price-restore-hardening")
-elif "shopify-section-template" in src:
-    changes.append("price-restore-already-present")
+    src = src.replace(old_restore, new_restore, 1); changes.append("price-restore-hardened")
 
-# 3b. variant buttons block, inserted after the first "<!-- /DW -->"
-BLOCK = """
+# --- variant buttons v2: Sample button guaranteed on EVERY sku ---
+BLOCK = r"""
 {% if template contains 'product' %}
-<!-- DW: variant option buttons — replace native dropdown with side-by-side name-only buttons (2026-06-21) -->
+<!-- DW: variant option buttons v2 — side-by-side name-only buttons; Sample button on every SKU (2026-06-21) -->
 <style>
   .dw-variant-btns{display:flex;gap:10px;margin:10px 0 4px;flex-wrap:wrap}
   .dw-variant-btns button{flex:1 1 0;min-width:120px;padding:12px 14px;border:1px solid #111;
@@ -85,68 +75,96 @@ BLOCK = """
 </style>
 <script>
 (function(){
-  function build(){
-    var sels=document.querySelectorAll('.single-option-selector,[id^="single-option-"]');
-    Array.prototype.forEach.call(sels,function(sel){
-      if(sel.dataset.dwBtnDone) return;
-      if(!sel.options||sel.options.length<2){ sel.dataset.dwBtnDone='1'; return; }
-      sel.dataset.dwBtnDone='1';
+  function nameOnly(t){ return (t||'').split(/\s+[-–]\s+\$/)[0].replace(/\s+/g,' ').trim(); }
+  function press(wrap,btn){ Array.prototype.forEach.call(wrap.querySelectorAll('button'),function(x){x.setAttribute('aria-pressed', x===btn?'true':'false');}); }
+  function hostOf(el){ return el.closest('.select-wrapper')||el.closest('.selector-wrapper')||el.parentElement; }
+  function mk(sel, onClick){
+    if(sel.dataset.dwBtnDone||!sel.options||sel.options.length<2) return false;
+    sel.dataset.dwBtnDone='1';
+    var wrap=document.createElement('div'); wrap.className='dw-variant-btns';
+    Array.prototype.forEach.call(sel.options,function(opt){
+      var b=document.createElement('button'); b.type='button';
+      b.textContent=nameOnly(opt.text); b.setAttribute('data-value',opt.value);
+      b.setAttribute('aria-pressed', opt.selected?'true':'false');
+      b.addEventListener('click',function(){ onClick(sel,opt); press(wrap,b); });
+      wrap.appendChild(b);
+    });
+    var host=hostOf(sel); host.classList.add('dw-hide-native'); host.parentElement.insertBefore(wrap,host);
+    return true;
+  }
+  function isSampleVar(v){ return /(^|[-_ ])sample\b/i.test(v.sku||'') || /sample/i.test(v.title||''); }
+  function fallbackFromJson(){
+    // last-resort GUARANTEE: build from the product's real variant list so a Sample
+    // button shows even on malformed single-variant ("Default Title" sample-only) SKUs.
+    if(window.__dwVbDone||document.querySelector('.dw-variant-btns')) return; window.__dwVbDone=1;
+    fetch(location.pathname.split('?')[0]+'.js').then(function(r){return r.json();}).then(function(p){
+      if(document.querySelector('.dw-variant-btns')||!p||!p.variants||!p.variants.length) return;
+      var idSel=document.querySelector('select[name="id"],[id^="product-select-"]');
+      // place in the buy box: prefer just above Add-to-cart, then the qty field, then the id-select host
+      var atc=document.querySelector('button[name="add"],[type="submit"][name="add"]')||
+              [].slice.call(document.querySelectorAll('button,input[type=submit]')).filter(function(b){return /add to cart/i.test(b.textContent||b.value||'');})[0];
+      var qty=document.querySelector('input[name="quantity"],input[type="number"]');
+      var anchor=(idSel&&hostOf(idSel))|| (qty&&(qty.closest('.product-form__input,.selector-wrapper,div')||qty)) || atc || document.querySelector('form[action*="/cart"]');
+      if(!anchor) return;
       var wrap=document.createElement('div'); wrap.className='dw-variant-btns';
-      Array.prototype.forEach.call(sel.options,function(opt){
-        var b=document.createElement('button');
-        b.type='button';
-        b.textContent=(opt.text||'').trim();
-        b.setAttribute('data-value',opt.value);
-        b.setAttribute('aria-pressed', opt.selected?'true':'false');
-        b.addEventListener('click',function(){
-          sel.value=opt.value;
-          sel.dispatchEvent(new Event('change',{bubbles:true}));
-          Array.prototype.forEach.call(wrap.querySelectorAll('button'),function(x){
-            x.setAttribute('aria-pressed', x===b?'true':'false');
-          });
-        });
+      p.variants.forEach(function(v){
+        var b=document.createElement('button'); b.type='button';
+        b.textContent=isSampleVar(v)?'Sample':nameOnly(v.title||v.name||'');
+        b.setAttribute('aria-pressed', String(p.variants.length===1||!!v.featured_image&&false));
+        b.addEventListener('click',function(){ var u=new URL(location.href); u.searchParams.set('variant',v.id); location.href=u.toString(); });
         wrap.appendChild(b);
       });
-      var host=sel.closest('.select-wrapper')||sel.closest('.selector-wrapper')||sel.parentElement;
-      host.classList.add('dw-hide-native');
-      host.parentElement.insertBefore(wrap,host);
-    });
+      if(idSel) hostOf(idSel).classList.add('dw-hide-native');
+      if(anchor.parentElement) anchor.parentElement.insertBefore(wrap,anchor); else anchor.appendChild(wrap);
+    }).catch(function(){});
   }
-  function init(){ build(); setTimeout(build,400); setTimeout(build,1200); }
-  if(document.readyState!=='loading') init();
-  else document.addEventListener('DOMContentLoaded',init);
+  function build(){
+    if(location.pathname.indexOf('/products/')===-1) return;
+    var rows=document.querySelectorAll('.single-option-selector,[id^="single-option-"]');
+    if(rows.length){
+      Array.prototype.forEach.call(rows,function(sel){
+        mk(sel,function(s,opt){ s.value=opt.value; s.dispatchEvent(new Event('change',{bubbles:true})); });
+      });
+    }
+    if(!document.querySelector('.dw-variant-btns')){
+      var idSel=document.querySelector('select[name="id"],[id^="product-select-"]');
+      if(idSel) mk(idSel,function(s,opt){
+        s.value=opt.value; s.dispatchEvent(new Event('change',{bubbles:true}));
+        try{ var u=new URL(location.href); u.searchParams.set('variant',opt.value); history.replaceState({},'',u); }catch(e){}
+      });
+    }
+  }
+  function init(){ build(); setTimeout(build,400); setTimeout(function(){ build(); fallbackFromJson(); },1200); }
+  if(document.readyState!=='loading') init(); else document.addEventListener('DOMContentLoaded',init);
 })();
 </script>
 <!-- /DW variant buttons -->
 {% endif %}
 """
-if "dw-variant-btns" in src:
-    changes.append("variant-buttons-already-present")
-elif "<!-- /DW -->" in src:
-    src = src.replace("<!-- /DW -->", "<!-- /DW -->\n" + BLOCK, 1); changes.append("variant-buttons-added")
+
+# strip any prior injected block (idempotent UPDATE in place), then re-insert
+src = re.sub(r"\n*\{%-?\s*if template contains 'product'\s*-?%\}.*?<!-- /DW variant buttons -->\s*\{%-?\s*endif\s*-?%\}\n*",
+             "\n", src, flags=re.S)
+if "<!-- /DW -->" in src:
+    src = src.replace("<!-- /DW -->", "<!-- /DW -->\n" + BLOCK, 1); changes.append("variant-buttons-v2")
+elif "</head>" in src:
+    src = src.replace("</head>", BLOCK + "\n</head>", 1); changes.append("variant-buttons-v2-before-head")
 else:
-    # no DW anchor: inject before </head>
-    src = src.replace("</head>", BLOCK + "\n</head>", 1); changes.append("variant-buttons-added-before-head")
+    src = BLOCK + src; changes.append("variant-buttons-v2-prepended")
 
-print("changes:", ", ".join(changes))
-if src == cur:
-    print("nothing to change (already applied). done."); sys.exit(0)
+print("changes:", ", ".join(changes) or "none")
+if src == cur: print("no change needed. done."); sys.exit(0)
 
-# 4. push transformed content back
 body = json.dumps({"asset": {"key": key, "value": src}}).encode()
-req = urllib.request.Request(f"https://{store}/admin/api/{api}/themes/{tid}/assets.json",
-                            data=body, method="PUT", headers=H)
 try:
-    r = urllib.request.urlopen(req); print("PUT HTTP", r.status, "OK", f"({len(src)} bytes)")
+    r = urllib.request.urlopen(urllib.request.Request(
+        f"https://{store}/admin/api/{api}/themes/{tid}/assets.json", data=body, method="PUT", headers=H))
+    print("PUT HTTP", r.status, "OK", f"({len(src)} bytes)")
 except urllib.error.HTTPError as e:
     print("PUT FAILED HTTP", e.code, e.read().decode()[:300]); sys.exit(1)
 
-# 5. verify
 ver = get(f"https://{store}/admin/api/{api}/themes/{tid}/assets.json?{q}")["asset"]["value"]
-print("verify: dw-variant-btns x", ver.count("dw-variant-btns"),
-      "| theme-agnostic restore:", "shopify-section-template" in ver)
-print()
-print("PREVIEW:")
-print(f"  https://www.designerwallcoverings.com/products/cirrus-jade-dwdx-220230?preview_theme_id={tid}")
-print(f"  revert: PUT {bk} back to {key} on theme {tid}")
+print("verify: dw-variant-btns x", ver.count("dw-variant-btns"), "| v2:", "variant option buttons v2" in ver)
+print(f"\nPREVIEW: https://www.designerwallcoverings.com/products/cirrus-jade-dwdx-220230?preview_theme_id={tid}")
+print(f"revert:  PUT {bk} back to {key} on theme {tid}")
 PY

← 9f4a59ef auto-save: 2026-06-21T19:01:26 (1 files) — shopify/scripts/g  ·  back to Designer Wallcoverings  ·  Sync 5 _cw* theme.liquid mirrors to v2 variant-button block c3326474 →