← back to Shopify Sample Shipping
TK-11333: authored sample-shipping executors (trade-grant-check, segment, grandfather, codes, prototype, theme engine, remove/restore band) + paste-ready runbook
5cb23415c5938d1eb5350e4dc6e88504283a07f4 · 2026-09-09 16:35:44 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GGVzyUM2B8rVz9TxouGgY
Files touched
A RUNBOOK-TK-11333.mdM create-freeship-codes.mjsA remove-band.mjsM restore-band.mjsA theme/sample-shipping-cart-engine.liquidA verification/proto-autoapply-report.json
Diff
commit 5cb23415c5938d1eb5350e4dc6e88504283a07f4
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Sep 9 16:35:44 2026 -0700
TK-11333: authored sample-shipping executors (trade-grant-check, segment, grandfather, codes, prototype, theme engine, remove/restore band) + paste-ready runbook
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GGVzyUM2B8rVz9TxouGgY
---
RUNBOOK-TK-11333.md | 101 +++++++++++++++++++
create-freeship-codes.mjs | 10 +-
remove-band.mjs | 58 +++++++++++
restore-band.mjs | 72 ++++++++++++--
theme/sample-shipping-cart-engine.liquid | 165 +++++++++++++++++++++++++++++++
verification/proto-autoapply-report.json | 13 +++
6 files changed, 407 insertions(+), 12 deletions(-)
diff --git a/RUNBOOK-TK-11333.md b/RUNBOOK-TK-11333.md
new file mode 100644
index 0000000..c663334
--- /dev/null
+++ b/RUNBOOK-TK-11333.md
@@ -0,0 +1,101 @@
+# RUNBOOK — TK-11333 Designer Sample-Shipping Policy (Option A)
+
+**Owner:** vp-dw-commerce · **Store:** designer-laboratory-sandbox (LIVE) · **Approved:** Steve 2026-09-09 (Option A, soft-launch+monitor, 5+5 = 5-of-each-type)
+
+Every step below is a paste Steve runs in his OWN session. Each executor is **dry-run by default** — it prints what it WOULD do; add `--apply` to write. Every write step has its undo listed right under it. **This authoring session did NOT run any `--apply`.**
+
+> **Grandfather vehicle = `sample-freeship` (dedicated tag), NOT `trade`.** Verified by `trade-grant-check.mjs`: `trade` on this store also gates PRICING (TRADECODE/TRADE15 15%-off codes scoped to the `trade` segment + `trade-only-benefits` page + `trade_approved` theme entitlement). Mass-tagging 2,663 grandfathered customers `trade` would leak those perks. The `DW Trade / Designers` segment ORs the real trade tags **plus** `sample-freeship`, so grandfathered designers get free sample shipping and nothing else.
+
+> **Live-state note (verified 2026-09-09):** the blunt `$0–$45` "Free Shipping (No Tracking)" band is **already gone** from every delivery profile — the General profile's Domestic zone is `ups_shipping` + `fedex` only. GAP2 is already closed; carrier-only base shipping is already live. So **Step 6 (remove-band) is a verified no-op today**, and the STOP GATE below flips to "if the prototype DEGRADES, RESTORE a fallback free band so eligible customers aren't charged while auto-apply is sorted."
+
+---
+
+## Preconditions (once)
+```
+! cd ~/Projects/shopify-sample-shipping && node trade-grant-check.mjs # re-confirm vehicle (read-only)
+```
+Pick a real **Sample variant id** to use in the prototype (any product's `{DW_SKU}-Sample`), e.g. from `verification/all-sample-variants.json`.
+
+---
+
+## STEP 1 — Grandfather TIER-1 designers (2,663) FIRST [protective, additive, reversible]
+Pilot 20 first, eyeball, then the full run. Resumable + idempotent; ledgers to `executed-reversible/ledger.jsonl`.
+```
+! cd ~/Projects/shopify-sample-shipping && node grandfather-apply.mjs --limit 20 # DRY pilot
+! cd ~/Projects/shopify-sample-shipping && node grandfather-apply.mjs --apply --limit 20 # WRITE pilot (20)
+! cd ~/Projects/shopify-sample-shipping && node grandfather-apply.mjs --apply # WRITE all pending (2,663)
+```
+**Undo:** `! cd ~/Projects/shopify-sample-shipping && node grandfather-undo.mjs --apply` (removes `sample-freeship` from exactly the tagged set; keeps any preexisting).
+
+---
+
+## STEP 2 — Create the segment + delete the broken one [reversible]
+Creates `DW Trade / Designers` (core tag set + `sample-freeship`); deletes broken `interior-designer-res` (snapshotted). Add `--with-confirm` to include the §4 "confirm-these" tags (Photography Studio, Graphic Designer, Illustrator, Visual Merchandiser, Production Company, Manufacturer, Developer) — default is CORE only.
+```
+! cd ~/Projects/shopify-sample-shipping && node create-trade-segment.mjs # DRY
+! cd ~/Projects/shopify-sample-shipping && node create-trade-segment.mjs --apply # WRITE
+```
+**Undo:** `! cd ~/Projects/shopify-sample-shipping && node create-trade-segment-undo.mjs --apply` (delete codes first if they exist).
+
+---
+
+## STEP 3 — Create the two free-shipping codes [reversible]
+`TRADESHIP` (segment-scoped, server-enforced) + `SAMPLESHIP` (all customers). Both `maximumShippingPrice $30`.
+```
+! cd ~/Projects/shopify-sample-shipping && node create-freeship-codes.mjs # DRY (prints exact mutations)
+! cd ~/Projects/shopify-sample-shipping && node create-freeship-codes.mjs --apply # WRITE (segment must exist)
+```
+**Undo:** `! cd ~/Projects/shopify-sample-shipping && node create-freeship-codes-undo.mjs --apply`
+
+---
+
+## STEP 4 — PROTOTYPE the auto-apply ⛔ STOP GATE ⛔
+This is the go/no-go (DTD dissent hole #1). In the **openclaw real Chrome (CDP 127.0.0.1:18800)**, log in as a throwaway **eligible (in-segment)** customer with a saved US address, then:
+```
+! cd ~/Projects/shopify-sample-shipping && node proto-autoapply.mjs --code TRADESHIP --variant <SAMPLE_VARIANT_ID> --checkout
+```
+Reads `verification/proto-autoapply-report.json`. **VERDICT:**
+- **PASS** (free shipping shows at checkout AND survives a reload) → proceed to Step 5.
+- **DEGRADE / UNKNOWN** → **DO NOT proceed to a no-band state.** Restore a fallback free band so eligible customers aren't charged while auto-apply is fixed:
+ ```
+ ! cd ~/Projects/shopify-sample-shipping && node restore-band.mjs --apply # recreate the $0 free band as fallback
+ ```
+ Then rethink auto-apply (permalink UX) before continuing. **Do not run Step 5/6 until PASS.**
+
+*(No real code yet? Steve can make a throwaway free-ship code in Admin and pass it as `--code` instead of `TRADESHIP`.)*
+
+---
+
+## STEP 5 — Theme cart policy engine [dev theme; Steve installs + publishes]
+File: `theme/sample-shipping-cart-engine.liquid` (starter + spec header). Counts samples by `product.type` (WC vs Fabric), reads `customer.tags`, auto-applies the right code when thresholds pass (designer ≤12; retail ≤5 WC AND ≤5 fabric), shows the "why charged" message, soft-caps at 20.
+1. Install as `snippets/sample-shipping-cart-engine.liquid` on a **DEV/duplicate theme**.
+2. Render near the cart checkout button: `{%- render 'sample-shipping-cart-engine' -%}`.
+3. Tune `CONFIG` (codes, caps, fabric product-types, checkout selector) to the live theme.
+4. QA carts (designer ≤12/13+, retail 5+5/6WC, 21+ soft-cap), then **Steve publishes the theme.**
+**Undo:** theme is version-backed (`verification/cart.liquid.backup`); remove the snippet render + file.
+
+---
+
+## STEP 6 — Remove the blunt $0 band [LAST — verified no-op today]
+```
+! cd ~/Projects/shopify-sample-shipping && node remove-band.mjs # DRY (reports "already absent")
+! cd ~/Projects/shopify-sample-shipping && node remove-band.mjs --apply # deactivate IF a band ever gets re-added
+```
+Today this prints "BAND ALREADY ABSENT" and exits — nothing to do. Only relevant if a free band gets re-added later.
+**Undo:** `! cd ~/Projects/shopify-sample-shipping && node restore-band.mjs --apply` (reactivates, or recreates from snapshot).
+
+---
+
+## STEP 7 — Soft-launch monitor (2 weeks)
+Watch retail-charged sample orders; auto-tag designer-looking ones `sample-freeship` (rerun `grandfather-apply` on new finds); add the self-serve "I'm trade" prompt at cart. TIER-2 (765) list in `verification/grandfather-list.json` is for Steve's manual review (not auto-tagged).
+
+---
+
+## Full rollback (reverse order)
+```
+! node create-freeship-codes-undo.mjs --apply
+! node create-trade-segment-undo.mjs --apply
+! node grandfather-undo.mjs --apply
+! node restore-band.mjs --apply # only if you had removed/deactivated a band
+```
+Each is enumerable, recorded, and non-destructive. Ledger: `~/.claude/yolo-queue/executed-reversible/ledger.jsonl`.
diff --git a/create-freeship-codes.mjs b/create-freeship-codes.mjs
index da7247c..cee7a88 100644
--- a/create-freeship-codes.mjs
+++ b/create-freeship-codes.mjs
@@ -22,9 +22,13 @@ const SEG_REC = new URL('./verification/trade-segment-created.json', import.meta
const OUT = new URL('./verification/freeship-codes-created.json', import.meta.url);
const LOGX = process.env.HOME + '/.claude/yolo-queue/executed-reversible/log-exec.mjs';
-if (!fs.existsSync(SEG_REC)) { console.error('MISSING verification/trade-segment-created.json — run create-trade-segment.mjs --apply FIRST'); process.exit(1); }
-const segId = JSON.parse(fs.readFileSync(SEG_REC, 'utf8')).segmentId;
-if (!segId) { console.error('no segmentId in trade-segment-created.json'); process.exit(1); }
+let segId;
+if (fs.existsSync(SEG_REC)) segId = JSON.parse(fs.readFileSync(SEG_REC, 'utf8')).segmentId;
+if (!segId) {
+ if (APPLY) { console.error('MISSING segmentId — run create-trade-segment.mjs --apply FIRST'); process.exit(1); }
+ segId = 'gid://shopify/Segment/PENDING'; // dry-run placeholder so the mutation shape prints
+ console.log('(dry-run: no segment created yet — using placeholder segId; create the segment before --apply)');
+}
const now = new Date().toISOString();
const combines = { orderDiscounts: true, productDiscounts: true, shippingDiscounts: false };
diff --git a/remove-band.mjs b/remove-band.mjs
new file mode 100644
index 0000000..c5d1e1a
--- /dev/null
+++ b/remove-band.mjs
@@ -0,0 +1,58 @@
+#!/usr/bin/env node
+// TK-11333 — remove the blunt "Free Shipping (No Tracking)" $0–$45 band so base shipping =
+// honest ups/fedex carrier rates (fixes GAP2: free untracked shipping leaking to ANY product
+// <= $45). Free SAMPLE shipping now comes from the segment/all-customer CODES instead.
+//
+// MECHANISM: the delivery API has NO methodDefinitionsToDelete, but a method has an `active`
+// flag — so we DEACTIVATE the free method (exact-GID, trivially reversible) rather than delete.
+// LAST STEP — run only AFTER grandfather + proto-autoapply PASS + codes exist.
+//
+// GATED (customer-facing shipping change). DRY-RUN BY DEFAULT; --apply to write.
+// Snapshots to verification/remove-band-snapshot.json; undo = restore-band.mjs (reactivates
+// + restores the exact prior conditions from that snapshot).
+import { query } from './query.mjs';
+import fs from 'node:fs';
+
+const APPLY = process.argv.includes('--apply');
+const PROFILE = 'gid://shopify/DeliveryProfile/29033627699';
+const METHOD_NAME = 'Free Shipping (No Tracking)';
+const SNAP = new URL('./verification/remove-band-snapshot.json', import.meta.url);
+const LOGX = process.env.HOME + '/.claude/yolo-queue/executed-reversible/log-exec.mjs';
+
+const rq = `query($id:ID!){deliveryProfile(id:$id){profileLocationGroups{locationGroup{id} locationGroupZones(first:20){nodes{zone{id name} methodDefinitions(first:40){nodes{id name active rateProvider{__typename ... on DeliveryRateDefinition{price{amount currencyCode}}} methodConditions{id operator conditionCriteria{__typename ... on MoneyV2{amount currencyCode}}}}}}}}}}`;
+const d = await query(rq, { id: PROFILE });
+let m = null, lg = null, zone = null;
+for (const g of d.deliveryProfile.profileLocationGroups) for (const z of g.locationGroupZones.nodes) {
+ if (z.zone.name !== 'Domestic') continue;
+ for (const md of z.methodDefinitions.nodes) if (md.name === METHOD_NAME) { m = md; lg = g.locationGroup.id; zone = z.zone.id; }
+}
+if (!m) {
+ // The $0 free band is already absent from the General profile's Domestic zone (verified
+ // 2026-09-09: Domestic = ups_shipping + fedex only). Removal is already done — idempotent success.
+ console.log('\nBAND ALREADY ABSENT — no "' + METHOD_NAME + '" method in the Domestic zone.');
+ console.log('GAP2 is already closed on the General profile: base domestic shipping = carrier (ups/fedex).');
+ console.log('Nothing to remove. (Free sample shipping must come from the CODES.)');
+ process.exit(0);
+}
+
+const snapshot = { at: new Date().toISOString(), profile: PROFILE, lg, zone, method: m };
+console.log('=== remove-band (' + (APPLY ? 'APPLY' : 'DRY-RUN') + ') ===');
+console.log('method:', m.name, '| active:', m.active, '| price:', m.rateProvider?.price?.amount);
+console.log('conditions:', m.methodConditions.map(c => `${c.operator} $${c.conditionCriteria?.amount}`).join(' & ') || '(none)');
+
+if (!m.active) { console.log('\nAlready INACTIVE — nothing to do (idempotent).'); fs.writeFileSync(SNAP, JSON.stringify(snapshot, null, 2) + '\n'); process.exit(0); }
+if (!APPLY) { console.log('\n-- WOULD set method active:false (deactivate the $0-band). Re-run with --apply.'); fs.writeFileSync(SNAP, JSON.stringify(snapshot, null, 2) + '\n'); console.log('snapshot -> verification/remove-band-snapshot.json'); process.exit(0); }
+
+fs.writeFileSync(SNAP, JSON.stringify(snapshot, null, 2) + '\n');
+const mut = `mutation($id:ID!,$profile:DeliveryProfileInput!){deliveryProfileUpdate(id:$id,profile:$profile){userErrors{field message}}}`;
+const r = (await query(mut, { id: PROFILE, profile: { locationGroupsToUpdate: [{ id: lg, zonesToUpdate: [{ id: zone, methodDefinitionsToUpdate: [{ id: m.id, active: false }] }] }] } })).deliveryProfileUpdate;
+if (r.userErrors?.length) { console.error('ERR', JSON.stringify(r.userErrors)); process.exit(1); }
+console.log('\nband DEACTIVATED. Base shipping is now carrier rates; free sample shipping via the CODES only.');
+console.log('snapshot -> verification/remove-band-snapshot.json (undo: node restore-band.mjs --apply)');
+try {
+ const { execSync } = await import('node:child_process');
+ execSync(`node ${LOGX} --agent vp-dw-commerce --ticket TK-11333 ` +
+ `--action ${JSON.stringify('deactivated "' + METHOD_NAME + '" $0-band (GAP2 fix)')} --blast 1 ` +
+ `--undo ${JSON.stringify('cd ~/Projects/shopify-sample-shipping && node restore-band.mjs --apply')} ` +
+ `--verify ${JSON.stringify('node live-rate-state.mjs')}`, { stdio: 'inherit' });
+} catch (e) { console.log('(ledger note skipped:', e.message, ')'); }
diff --git a/restore-band.mjs b/restore-band.mjs
index 2e71e6d..bafa252 100644
--- a/restore-band.mjs
+++ b/restore-band.mjs
@@ -1,9 +1,63 @@
-import {query} from './query.mjs';
-const PROFILE='gid://shopify/DeliveryProfile/29033627699';
-const rq=`query($id:ID!){deliveryProfile(id:$id){profileLocationGroups{locationGroup{id} locationGroupZones(first:20){nodes{zone{id name} methodDefinitions(first:30){nodes{id name methodConditions{id}}}}}}}}`;
-const d=await query(rq,{id:PROFILE});
-let m,lg,zone;for(const g of d.deliveryProfile.profileLocationGroups)for(const z of g.locationGroupZones.nodes){if(z.zone.name!=='Domestic')continue;for(const md of z.methodDefinitions.nodes)if(md.name==='Free Shipping (No Tracking)'){m=md;lg=g.locationGroup.id;zone=z.zone.id;}}
-const mut=`mutation($id:ID!,$profile:DeliveryProfileInput!){deliveryProfileUpdate(id:$id,profile:$profile){userErrors{field message}}}`;
-await query(mut,{id:PROFILE,profile:{conditionsToDelete:m.methodConditions.map(c=>c.id)}});
-const add=await query(mut,{id:PROFILE,profile:{locationGroupsToUpdate:[{id:lg,zonesToUpdate:[{id:zone,methodDefinitionsToUpdate:[{id:m.id,priceConditionsToCreate:[{operator:'GREATER_THAN_OR_EQUAL_TO',criteria:{amount:'0',currencyCode:'USD'}},{operator:'LESS_THAN_OR_EQUAL_TO',criteria:{amount:'30',currencyCode:'USD'}}]}]}]}]}});
-console.log('restored to $0-$30:',JSON.stringify(add.deliveryProfileUpdate.userErrors));
+#!/usr/bin/env node
+// TK-11333 — UNDO for remove-band.mjs (and general "put the free band back"). Snapshot-driven
+// TRUE inverse with two paths:
+// • method PRESENT (was deactivated) -> reactivate + restore exact prior conditions.
+// • method ABSENT (was deleted, or never present) -> RECREATE it from the snapshot
+// (DeliveryRateDefinition $0 + the snapshot's price conditions) in the Domestic zone.
+// Snapshot source: verification/remove-band-snapshot.json, else raise-band-snapshot.json.
+// DRY-RUN BY DEFAULT; --apply to write.
+import { query } from './query.mjs';
+import fs from 'node:fs';
+
+const APPLY = process.argv.includes('--apply');
+const PROFILE = 'gid://shopify/DeliveryProfile/29033627699';
+const METHOD_NAME = 'Free Shipping (No Tracking)';
+const CANDIDATES = ['./verification/remove-band-snapshot.json', './verification/raise-band-snapshot.json'];
+let snap = null, snapPath = null;
+for (const p of CANDIDATES) { const u = new URL(p, import.meta.url); if (fs.existsSync(u)) { snap = JSON.parse(fs.readFileSync(u, 'utf8')); snapPath = p; break; } }
+if (!snap) { console.error('no band snapshot found — expected verification/remove-band-snapshot.json'); process.exit(1); }
+const snapConds = (snap.method?.methodConditions || []).filter(c => c.conditionCriteria?.amount !== undefined)
+ .map(c => ({ operator: c.operator, amount: String(parseFloat(c.conditionCriteria.amount)) }));
+const snapPrice = snap.method?.rateProvider?.price?.amount != null ? String(parseFloat(snap.method.rateProvider.price.amount)) : '0';
+
+console.log('=== restore-band (' + (APPLY ? 'APPLY' : 'DRY-RUN') + ') using ' + snapPath + ' ===');
+console.log('band to restore: price $' + snapPrice + ' conditions:', snapConds.map(c => `${c.operator} $${c.amount}`).join(' & ') || '(none)');
+
+// same working query as remove-band (includes rateProvider so brace count is correct)
+const rq = `query($id:ID!){deliveryProfile(id:$id){profileLocationGroups{locationGroup{id} locationGroupZones(first:20){nodes{zone{id name} methodDefinitions(first:40){nodes{id name active rateProvider{__typename ... on DeliveryRateDefinition{price{amount currencyCode}}} methodConditions{id operator conditionCriteria{__typename ... on MoneyV2{amount currencyCode}}}}}}}}}}`;
+const d = await query(rq, { id: PROFILE });
+let m = null, lg = null, zone = null;
+for (const g of d.deliveryProfile.profileLocationGroups) for (const z of g.locationGroupZones.nodes) {
+ if (z.zone.name !== 'Domestic') continue;
+ lg = g.locationGroup.id; zone = z.zone.id;
+ for (const md of z.methodDefinitions.nodes) if (md.name === METHOD_NAME) m = md;
+}
+if (!lg || !zone) { console.error('ABORT: Domestic zone not found'); process.exit(1); }
+console.log(m ? `live method present (active=${m.active})` : 'live method ABSENT — will RECREATE');
+
+if (!APPLY) { console.log('\nDry-run only. Re-run with --apply.'); process.exit(0); }
+const mut = `mutation($id:ID!,$profile:DeliveryProfileInput!){deliveryProfileUpdate(id:$id,profile:$profile){userErrors{field message}}}`;
+
+if (m) {
+ // reactivate
+ const act = (await query(mut, { id: PROFILE, profile: { locationGroupsToUpdate: [{ id: lg, zonesToUpdate: [{ id: zone, methodDefinitionsToUpdate: [{ id: m.id, active: true }] }] }] } })).deliveryProfileUpdate;
+ if (act.userErrors?.length) { console.error('reactivate ERR', JSON.stringify(act.userErrors)); process.exit(1); }
+ const live = m.methodConditions.map(c => `${c.operator}:${parseFloat(c.conditionCriteria?.amount)}`).sort().join('|');
+ const want = snapConds.map(c => `${c.operator}:${parseFloat(c.amount)}`).sort().join('|');
+ if (snapConds.length && live !== want) {
+ if (m.methodConditions.length) await query(mut, { id: PROFILE, profile: { conditionsToDelete: m.methodConditions.map(c => c.id) } });
+ const add = (await query(mut, { id: PROFILE, profile: { locationGroupsToUpdate: [{ id: lg, zonesToUpdate: [{ id: zone, methodDefinitionsToUpdate: [{ id: m.id, priceConditionsToCreate: snapConds.map(c => ({ operator: c.operator, criteria: { amount: c.amount, currencyCode: 'USD' } })) }] }] }] } })).deliveryProfileUpdate;
+ if (add.userErrors?.length) { console.error('conditions ERR', JSON.stringify(add.userErrors)); process.exit(1); }
+ }
+ console.log('reactivated + conditions restored.');
+} else {
+ // recreate the method from scratch
+ const create = (await query(mut, { id: PROFILE, profile: { locationGroupsToUpdate: [{ id: lg, zonesToUpdate: [{ id: zone, methodDefinitionsToCreate: [{
+ name: METHOD_NAME, active: true,
+ rateDefinition: { price: { amount: snapPrice, currencyCode: 'USD' } },
+ priceConditionsToCreate: snapConds.map(c => ({ operator: c.operator, criteria: { amount: c.amount, currencyCode: 'USD' } })),
+ }] }] }] } })).deliveryProfileUpdate;
+ if (create.userErrors?.length) { console.error('recreate ERR', JSON.stringify(create.userErrors)); process.exit(1); }
+ console.log('recreated method "' + METHOD_NAME + '" $' + snapPrice + ' with band ' + snapConds.map(c => `${c.operator} $${c.amount}`).join(' & '));
+}
+console.log('\nband RESTORED.');
diff --git a/theme/sample-shipping-cart-engine.liquid b/theme/sample-shipping-cart-engine.liquid
new file mode 100644
index 0000000..6a3d224
--- /dev/null
+++ b/theme/sample-shipping-cart-engine.liquid
@@ -0,0 +1,165 @@
+{%- comment -%}
+ ============================================================================
+ TK-11333 — SAMPLE-SHIPPING CART POLICY ENGINE (starter, NEEDS DEV THEME)
+ ============================================================================
+ STATUS: STARTER IMPLEMENTATION. Do NOT paste into the LIVE theme. Install on a
+ DEV/duplicate theme, QA with real carts, then Steve publishes. Go-live is gated
+ on proto-autoapply.mjs returning PASS (does the /discount/CODE permalink make free
+ shipping persist to checkout for an eligible logged-in customer?).
+
+ WHY THIS EXISTS (platform reality): the store is Advanced, NO Shopify Functions,
+ so a shipping RATE cannot be gated by customer tag nor count samples by type. The
+ ONLY place counts/types/tag-logic can live is theme JS on the CART/product pages.
+ The SERVER-ENFORCED gate is the segment-scoped free-ship CODE (TRADESHIP); this
+ engine only AUTO-APPLIES the correct code when thresholds pass and MESSAGES when not.
+
+ SPEC (locked, memo §1/§3):
+ • Eligibility: DESIGNER if customer.tags includes any trade tag OR 'sample-freeship';
+ otherwise RETAIL.
+ • Count SAMPLE line items only, split by product.type: Wallcovering vs Fabric.
+ (A line is a "sample" if variant.title == 'Sample' or sku ends in '-Sample'.)
+ • DESIGNER free rule: total samples <= 12 -> apply TRADESHIP (free ship).
+ 13+ -> do NOT apply; show the "why you're charged" message.
+ • RETAIL free rule: WC samples <= 5 AND Fabric samples <= 5 -> apply SAMPLESHIP.
+ Over EITHER type -> show the "why you're charged" message (>5 of that type).
+ • SOFT CAP 20 samples/order: at 21+ disable the checkout button + message
+ (theme-soft; a hard cap needs Plus/Functions).
+ • Removal note: on Advanced there is no clean in-cart "remove code" API. This engine
+ APPLIES when eligible and MESSAGES when not; a stale code is dropped at checkout
+ (maximumShippingPrice '30' on both codes stops a freight roll ever riding free).
+
+ INSTALL:
+ 1. Save this file as snippets/sample-shipping-cart-engine.liquid on a DEV theme.
+ 2. In the cart template/section, render it near the checkout button:
+ {%- render 'sample-shipping-cart-engine' -%}
+ 3. Confirm the CODES exist (create-freeship-codes.mjs) before enabling apply.
+ 4. Adjust CONFIG below (codes, caps, product-type labels, selectors) to the theme.
+ ============================================================================
+{%- endcomment -%}
+
+<div id="dw-ship-policy" data-dw-ship-policy hidden></div>
+<script type="application/json" id="dw-ship-cart-data">
+{
+ "isLoggedIn": {{ customer | default: false | json }},
+ "tags": {{ customer.tags | default: '' | json }},
+ "lines": [
+ {%- for item in cart.items -%}
+ {
+ "product_type": {{ item.product.type | default: '' | json }},
+ "variant_title": {{ item.variant.title | default: '' | json }},
+ "sku": {{ item.sku | default: '' | json }},
+ "quantity": {{ item.quantity }}
+ }{%- unless forloop.last -%},{%- endunless -%}
+ {%- endfor -%}
+ ]
+}
+</script>
+
+<script>
+(function () {
+ // ---------------- CONFIG (tune to the store) ----------------
+ var CONFIG = {
+ designerCode: 'TRADESHIP',
+ retailCode: 'SAMPLESHIP',
+ designerCap: 12, // total samples free for designers
+ retailWC: 5, // free wallcovering samples for retail
+ retailFabric: 5, // free fabric samples for retail
+ softCap: 20, // block checkout above this many samples
+ // customer tags that mean "gets designer free shipping" (mirror the DW Trade/Designers segment)
+ designerTags: ['trade','trade_approved','sample-freeship','interior designer',
+ 'interior design','interior','architect','contractor','commercial property owner',
+ 'wallcovering installer','interior designer - residential','interior designer - commercial'],
+ // product.type values that count as Fabric; everything else with a sample counts as Wallcovering
+ fabricTypes: ['fabric','fabrics','textile','textiles'],
+ checkoutBtnSelector: '[name="checkout"], button[name="checkout"], .cart__checkout, #checkout',
+ messageMountSelector: '#dw-ship-policy'
+ };
+ var APPLIED_KEY = 'dw_ship_code_applied'; // sessionStorage guard against redirect loops
+
+ function readData() {
+ try { return JSON.parse(document.getElementById('dw-ship-cart-data').textContent); }
+ catch (e) { return { isLoggedIn: false, tags: '', lines: [] }; }
+ }
+ function isSample(l) {
+ return (l.variant_title || '').toLowerCase() === 'sample' || /-sample$/i.test(l.sku || '');
+ }
+ function isFabric(l) {
+ var t = (l.product_type || '').toLowerCase();
+ return CONFIG.fabricTypes.indexOf(t) !== -1;
+ }
+ function isDesigner(tags) {
+ var set = String(tags || '').toLowerCase().split(',').map(function (s) { return s.trim(); });
+ return CONFIG.designerTags.some(function (t) { return set.indexOf(t) !== -1; });
+ }
+
+ function evaluate(d) {
+ var samples = d.lines.filter(isSample);
+ var total = samples.reduce(function (n, l) { return n + l.quantity; }, 0);
+ var wc = samples.filter(function (l) { return !isFabric(l); }).reduce(function (n, l) { return n + l.quantity; }, 0);
+ var fab = samples.filter(isFabric).reduce(function (n, l) { return n + l.quantity; }, 0);
+ var designer = d.isLoggedIn && isDesigner(d.tags);
+ var overSoftCap = total > CONFIG.softCap;
+ var eligibleFree, code, reason;
+ if (designer) {
+ eligibleFree = total <= CONFIG.designerCap;
+ code = CONFIG.designerCode;
+ reason = eligibleFree ? '' : ('Free sample shipping covers up to ' + CONFIG.designerCap +
+ ' samples for trade accounts. You have ' + total + ' — shipping on the rest is charged at cost.');
+ } else {
+ eligibleFree = wc <= CONFIG.retailWC && fab <= CONFIG.retailFabric;
+ code = CONFIG.retailCode;
+ if (!eligibleFree) {
+ var bits = [];
+ if (wc > CONFIG.retailWC) bits.push(wc + ' wallcovering (free up to ' + CONFIG.retailWC + ')');
+ if (fab > CONFIG.retailFabric) bits.push(fab + ' fabric (free up to ' + CONFIG.retailFabric + ')');
+ reason = 'Free sample shipping covers ' + CONFIG.retailWC + ' wallcovering and ' + CONFIG.retailFabric +
+ ' fabric samples. You have ' + bits.join(' and ') + ' — shipping on the extras is charged.';
+ } else reason = '';
+ }
+ return { total: total, wc: wc, fab: fab, designer: designer, eligibleFree: eligibleFree, code: code, reason: reason, overSoftCap: overSoftCap };
+ }
+
+ function applyCode(code) {
+ // /discount/CODE?redirect=/cart sets the session discount cookie. Guard against loops:
+ // only redirect once per cart state (keyed on code+item signature).
+ var sig = code + ':' + (document.getElementById('dw-ship-cart-data').textContent.length);
+ if (sessionStorage.getItem(APPLIED_KEY) === sig) return;
+ sessionStorage.setItem(APPLIED_KEY, sig);
+ window.location.assign('/discount/' + encodeURIComponent(code) + '?redirect=/cart');
+ }
+
+ function render(state) {
+ var mount = document.querySelector(CONFIG.messageMountSelector);
+ if (mount) {
+ mount.hidden = false;
+ if (state.overSoftCap) {
+ mount.innerHTML = '<div class="dw-ship-msg dw-ship-msg--block" role="alert" style="padding:.75rem 1rem;border:1px solid #b00;border-radius:8px;margin:.5rem 0;color:#b00;">' +
+ 'Sample orders are limited to ' + CONFIG.softCap + ' per order. Please reduce to ' + CONFIG.softCap + ' or fewer to check out.</div>';
+ } else if (state.reason) {
+ mount.innerHTML = '<div class="dw-ship-msg" role="status" style="padding:.75rem 1rem;border:1px solid #d8c9a8;border-radius:8px;margin:.5rem 0;background:#faf6ee;">' +
+ state.reason + '</div>';
+ } else if (state.eligibleFree) {
+ mount.innerHTML = '<div class="dw-ship-msg dw-ship-msg--ok" role="status" style="padding:.5rem 1rem;color:#2e6b2e;">Free sample shipping applied.</div>';
+ } else { mount.innerHTML = ''; }
+ }
+ // soft cap: disable checkout
+ var btns = document.querySelectorAll(CONFIG.checkoutBtnSelector);
+ btns.forEach(function (b) {
+ if (state.overSoftCap) { b.setAttribute('disabled', 'disabled'); b.setAttribute('aria-disabled', 'true'); b.style.opacity = '0.5'; b.style.pointerEvents = 'none'; }
+ else { b.removeAttribute('disabled'); b.removeAttribute('aria-disabled'); b.style.opacity = ''; b.style.pointerEvents = ''; }
+ });
+ }
+
+ function run() {
+ var d = readData();
+ var state = evaluate(d);
+ render(state);
+ // auto-apply the correct code only when the cart is all-eligible-free and NOT over soft cap
+ if (state.total > 0 && state.eligibleFree && !state.overSoftCap) applyCode(state.code);
+ // (removal is checkout-side on Advanced; maximumShippingPrice '30' guards a stale code)
+ }
+
+ if (document.readyState !== 'loading') run();
+ else document.addEventListener('DOMContentLoaded', run);
+})();
+</script>
diff --git a/verification/proto-autoapply-report.json b/verification/proto-autoapply-report.json
new file mode 100644
index 0000000..ea5749f
--- /dev/null
+++ b/verification/proto-autoapply-report.json
@@ -0,0 +1,13 @@
+{
+ "at": "2026-09-09T23:31:53.343Z",
+ "code": "TESTONLY",
+ "domain": "designerwallcoverings.com",
+ "variant": null,
+ "steps": {
+ "loggedIn": true
+ },
+ "verdict": "UNKNOWN",
+ "notes": [
+ "harness error: page.evaluate: BreadcrumbsPluginFetchError: Failed to fetch\n at https://cdn.shopify.com/shopifycloud/customer-authentication-web/production/assets/index.C3c9O3k-.js:33:8758"
+ ]
+}
← 3cd0d11 TK-11333: hard-delete the 3 test orders (final cleanup)
·
back to Shopify Sample Shipping
·
auto-data-snapshot: 2026-09-09T16:44:54 (2 data files) — ver eefb2df →