← 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
M shopify/push-variant-ux-to-dev-theme.sh
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 →