← back to Designer Wallcoverings
auto-save: 2026-06-30T04:58:21 (6 files) — pending-approval/boost-filter-consolidation-2026-06-25 shopify/scripts/cadence/data/cadence-cursor.json shopify/scripts/cadence/data/cadence-plan-am-2026-06-30.json shopify/scripts/cadence/data/upload-restore-2026-06-30.jsonl vendor-scrapers/china-seas-refresh
fef16d472664de90536e86714f4e1d82874a80e2 · 2026-06-30 04:58:28 -0700 · Steve Abrams
Files touched
M shopify/scripts/cadence/data/cadence-cursor.jsonM shopify/scripts/cadence/data/cadence-plan-am-2026-06-30.jsonM shopify/scripts/cadence/data/upload-restore-2026-06-30.jsonlA shopify/staged/free-samples-function/extensions/free-samples-discount/package.jsonA shopify/staged/free-samples-function/extensions/free-samples-discount/shopify.extension.tomlA shopify/staged/free-samples-function/extensions/free-samples-discount/src/run.graphqlA shopify/staged/free-samples-function/extensions/free-samples-discount/src/run.jsA shopify/staged/free-samples-function/extensions/free-samples-discount/src/run.test.jsA shopify/staged/free-samples-function/shopify.app.toml
Diff
commit fef16d472664de90536e86714f4e1d82874a80e2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Jun 30 04:58:28 2026 -0700
auto-save: 2026-06-30T04:58:21 (6 files) — pending-approval/boost-filter-consolidation-2026-06-25 shopify/scripts/cadence/data/cadence-cursor.json shopify/scripts/cadence/data/cadence-plan-am-2026-06-30.json shopify/scripts/cadence/data/upload-restore-2026-06-30.jsonl vendor-scrapers/china-seas-refresh
---
shopify/scripts/cadence/data/cadence-cursor.json | 2 +-
.../cadence/data/cadence-plan-am-2026-06-30.json | 212 ++++++++++-----------
.../cadence/data/upload-restore-2026-06-30.jsonl | 18 ++
.../extensions/free-samples-discount/package.json | 15 ++
.../free-samples-discount/shopify.extension.toml | 33 ++++
.../free-samples-discount/src/run.graphql | 62 ++++++
.../extensions/free-samples-discount/src/run.js | 140 ++++++++++++++
.../free-samples-discount/src/run.test.js | 84 ++++++++
.../staged/free-samples-function/shopify.app.toml | 26 +++
9 files changed, 485 insertions(+), 107 deletions(-)
diff --git a/shopify/scripts/cadence/data/cadence-cursor.json b/shopify/scripts/cadence/data/cadence-cursor.json
index 3d2915f0..79f2be2c 100644
--- a/shopify/scripts/cadence/data/cadence-cursor.json
+++ b/shopify/scripts/cadence/data/cadence-cursor.json
@@ -1,5 +1,5 @@
{
- "idx": 7,
+ "idx": 0,
"lastSlot": "am",
"lastRun": "2026-06-30"
}
\ No newline at end of file
diff --git a/shopify/scripts/cadence/data/cadence-plan-am-2026-06-30.json b/shopify/scripts/cadence/data/cadence-plan-am-2026-06-30.json
index c787deec..60bdc20b 100644
--- a/shopify/scripts/cadence/data/cadence-plan-am-2026-06-30.json
+++ b/shopify/scripts/cadence/data/cadence-plan-am-2026-06-30.json
@@ -1,182 +1,182 @@
[
{
- "vendor": "Thibaut",
- "dw_sku": "DWTT-73587",
- "cost": 5805,
- "retail": 10506.79,
+ "vendor": "Anna French",
+ "dw_sku": "DWAT-65115",
+ "cost": 51.8,
+ "retail": 93.76,
"sample": "4.25",
"sampleOnly": false,
- "title": "Savery Mural, Grey Wallcoverings | Thibaut",
+ "title": "Ribbon Sisal, Cream Wallcoverings | Anna French",
"willActivate": true
},
{
- "vendor": "Thibaut",
- "dw_sku": "DWTT-73588",
- "cost": 5805,
- "retail": 10506.79,
+ "vendor": "Anna French",
+ "dw_sku": "DWAT-65116",
+ "cost": 53.2,
+ "retail": 96.29,
"sample": "4.25",
"sampleOnly": false,
- "title": "Savery Mural, Sepia Wallcoverings | Thibaut",
+ "title": "Ribbon Sisal, Sage Wallcoverings | Anna French",
"willActivate": true
},
{
- "vendor": "Thibaut",
- "dw_sku": "DWTT-73589",
- "cost": 5805,
- "retail": 10506.79,
+ "vendor": "Anna French",
+ "dw_sku": "DWAT-65117",
+ "cost": 51.38,
+ "retail": 93,
"sample": "4.25",
"sampleOnly": false,
- "title": "Savery Mural, Blue Wallcoverings | Thibaut",
+ "title": "Ribbon Sisal, Beige Wallcoverings | Anna French",
"willActivate": true
},
{
- "vendor": "Designtex",
- "dw_sku": "DWDX-220450",
- "cost": 56,
- "retail": 101.36,
+ "vendor": "Quadrille",
+ "dw_sku": "DWQC-600132",
+ "cost": 0,
+ "retail": 0,
"sample": "4.25",
- "sampleOnly": false,
- "title": "Unwind, Sisal Wallcoverings | Designtex",
+ "sampleOnly": true,
+ "title": "Chantilly Stripe, Soft Lavender on Tint Wallcoverings | Quadrille",
"willActivate": true
},
{
- "vendor": "Designtex",
- "dw_sku": "DWDX-220451",
- "cost": 56,
- "retail": 101.36,
+ "vendor": "Quadrille",
+ "dw_sku": "DWQC-600133",
+ "cost": 0,
+ "retail": 0,
"sample": "4.25",
- "sampleOnly": false,
- "title": "Unwind, Jute Wallcoverings | Designtex",
+ "sampleOnly": true,
+ "title": "Chantilly Stripe, Rose Red on Tint Wallcoverings | Quadrille",
"willActivate": true
},
{
- "vendor": "Designtex",
- "dw_sku": "DWDX-220452",
- "cost": 56,
- "retail": 101.36,
+ "vendor": "Quadrille",
+ "dw_sku": "DWQC-600134",
+ "cost": 0,
+ "retail": 0,
"sample": "4.25",
- "sampleOnly": false,
- "title": "Unwind, Cotton Wallcoverings | Designtex",
+ "sampleOnly": true,
+ "title": "Chantilly Stripe, Slate Blue on Tint Wallcoverings | Quadrille",
"willActivate": true
},
{
- "vendor": "Newwall",
- "dw_sku": "DWXW-1005246",
- "cost": 297.6,
- "retail": 538.64,
+ "vendor": "Alan Campbell",
+ "dw_sku": "DWAK-700133",
+ "cost": 0,
+ "retail": 0,
"sample": "4.25",
- "sampleOnly": false,
- "title": "Jellyfish Ivory, Ivory Wallcoverings | Newwall",
+ "sampleOnly": true,
+ "title": "Brown on Tint Wallcoverings | Alan Campbell",
"willActivate": true
},
{
- "vendor": "Newwall",
- "dw_sku": "DWXW-1005247",
- "cost": 297.6,
- "retail": 538.64,
+ "vendor": "Alan Campbell",
+ "dw_sku": "DWAK-700134",
+ "cost": 0,
+ "retail": 0,
"sample": "4.25",
- "sampleOnly": false,
- "title": "Jellyfish Grey, Grey Wallcoverings | Newwall",
+ "sampleOnly": true,
+ "title": "Black on Tint Wallcoverings | Alan Campbell",
"willActivate": true
},
{
- "vendor": "Newwall",
- "dw_sku": "DWXW-1005248",
- "cost": 297.6,
- "retail": 538.64,
+ "vendor": "Alan Campbell",
+ "dw_sku": "DWAK-700135",
+ "cost": 0,
+ "retail": 0,
"sample": "4.25",
- "sampleOnly": false,
- "title": "Jellyfish Navy, Navy Wallcoverings | Newwall",
+ "sampleOnly": true,
+ "title": "Greens Turquoise on Tint Wallcoverings | Alan Campbell",
"willActivate": true
},
{
- "vendor": "Brewster & York",
- "dw_sku": "DWBR-170807",
- "cost": 45,
- "retail": 81.45,
+ "vendor": "Home Couture",
+ "dw_sku": "DWHJ-900131",
+ "cost": 0,
+ "retail": 0,
"sample": "4.25",
- "sampleOnly": false,
- "title": "Lottie, Slate Wallcoverings | Malibu Wallcovering",
+ "sampleOnly": true,
+ "title": "Melon/Camel on Cream Linen Wallcoverings | Home Couture",
"willActivate": true
},
{
- "vendor": "Brewster & York",
- "dw_sku": "DWBR-170808",
- "cost": 90,
- "retail": 162.9,
+ "vendor": "Home Couture",
+ "dw_sku": "DWHJ-900132",
+ "cost": 0,
+ "retail": 0,
"sample": "4.25",
- "sampleOnly": false,
- "title": "Lottie, Rose Wallcoverings | Malibu Wallcovering",
+ "sampleOnly": true,
+ "title": "Brown/Navy on Cream Linen Wallcoverings | Home Couture",
"willActivate": true
},
{
- "vendor": "Brewster & York",
- "dw_sku": "DWBR-170809",
- "cost": 90,
- "retail": 162.9,
+ "vendor": "Home Couture",
+ "dw_sku": "DWHJ-900133",
+ "cost": 0,
+ "retail": 0,
"sample": "4.25",
- "sampleOnly": false,
- "title": "Lottie, Yellow Wallcoverings | Malibu Wallcovering",
+ "sampleOnly": true,
+ "title": "Pink/Taupe on Cream Linen Wallcoverings | Home Couture",
"willActivate": true
},
{
- "vendor": "Kravet",
- "dw_sku": "DWKK-102847",
- "cost": 104.45,
- "retail": 156.67,
+ "vendor": "Suncloth",
+ "dw_sku": "DWUX-800134",
+ "cost": 0,
+ "retail": 0,
"sample": "4.25",
- "sampleOnly": false,
- "title": "Fomo, Dark Olive Wallcoverings | Kravet",
+ "sampleOnly": true,
+ "title": "New Shrimp Wallcoverings | Suncloth",
"willActivate": true
},
{
- "vendor": "Kravet",
- "dw_sku": "DWKK-102848",
- "cost": 104.45,
- "retail": 156.67,
+ "vendor": "Suncloth",
+ "dw_sku": "DWUX-800135",
+ "cost": 0,
+ "retail": 0,
"sample": "4.25",
- "sampleOnly": false,
- "title": "Fomo, Burgundy Wallcoverings | Kravet",
+ "sampleOnly": true,
+ "title": "China Blue on White Wallcoverings | Suncloth",
"willActivate": true
},
{
- "vendor": "Kravet",
- "dw_sku": "DWKK-102849",
- "cost": 69.9,
- "retail": 104.85,
+ "vendor": "Suncloth",
+ "dw_sku": "DWUX-800136",
+ "cost": 0,
+ "retail": 0,
"sample": "4.25",
- "sampleOnly": false,
- "title": "Recoup, Gray Wallcoverings | Kravet",
- "willActivate": true
+ "sampleOnly": true,
+ "title": "Multi Navy Wallcoverings | Suncloth",
+ "willActivate": false
},
{
- "vendor": "Schumacher",
- "dw_sku": "DWLK-806520",
- "cost": 42,
- "retail": 76.02,
+ "vendor": "Plains",
+ "dw_sku": "DWYP-1000131",
+ "cost": 0,
+ "retail": 0,
"sample": "4.25",
- "sampleOnly": false,
- "title": "Tumble Weed, Silver White Wallcoverings | Schumacher",
+ "sampleOnly": true,
+ "title": "Tweed, Turquoise Ivory Wallcoverings | Plains",
"willActivate": true
},
{
- "vendor": "Schumacher",
- "dw_sku": "DWLK-806530",
- "cost": 42,
- "retail": 76.02,
+ "vendor": "Plains",
+ "dw_sku": "DWYP-1000132",
+ "cost": 0,
+ "retail": 0,
"sample": "4.25",
- "sampleOnly": false,
- "title": "Tumble Weed, Bluebell Wallcoverings | Schumacher",
+ "sampleOnly": true,
+ "title": "Tweed, Gray Ivory Wallcoverings | Plains",
"willActivate": true
},
{
- "vendor": "Schumacher",
- "dw_sku": "DWLK-806540",
- "cost": 32,
- "retail": 57.92,
+ "vendor": "Plains",
+ "dw_sku": "DWYP-1000133",
+ "cost": 0,
+ "retail": 0,
"sample": "4.25",
- "sampleOnly": false,
- "title": "Thistle, Ivory Wallcoverings | Schumacher",
+ "sampleOnly": true,
+ "title": "Camden Cloth, Taupe Wallcoverings | Plains",
"willActivate": true
}
]
\ No newline at end of file
diff --git a/shopify/scripts/cadence/data/upload-restore-2026-06-30.jsonl b/shopify/scripts/cadence/data/upload-restore-2026-06-30.jsonl
index 02529ae6..ca5e23fb 100644
--- a/shopify/scripts/cadence/data/upload-restore-2026-06-30.jsonl
+++ b/shopify/scripts/cadence/data/upload-restore-2026-06-30.jsonl
@@ -161,3 +161,21 @@
{"table":"schumacher_catalog","mfr_sku":"5011421","dw_sku":"DWLK-806520","shopify_product_id":"7870977114163","action":"created","activated":true,"published":true,"sampleOnly":false,"status":"ACTIVE","batch_id":"upload-am-2026-06-30T10-40-00-616Z","ts":"2026-06-30T10:41:02.996Z"}
{"table":"schumacher_catalog","mfr_sku":"5011422","dw_sku":"DWLK-806530","shopify_product_id":"7870977146931","action":"created","activated":true,"published":true,"sampleOnly":false,"status":"ACTIVE","batch_id":"upload-am-2026-06-30T10-40-00-616Z","ts":"2026-06-30T10:41:06.446Z"}
{"table":"schumacher_catalog","mfr_sku":"5011430","dw_sku":"DWLK-806540","shopify_product_id":"7870977212467","action":"created","activated":true,"published":true,"sampleOnly":false,"status":"ACTIVE","batch_id":"upload-am-2026-06-30T10-40-00-616Z","ts":"2026-06-30T10:41:10.174Z"}
+{"table":"anna_french_catalog","mfr_sku":"AT16143","dw_sku":"DWAT-65115","shopify_product_id":"7871000018995","action":"created","activated":true,"published":true,"sampleOnly":false,"status":"ACTIVE","batch_id":"upload-am-2026-06-30T11-40-03-557Z","ts":"2026-06-30T11:40:09.242Z"}
+{"table":"anna_french_catalog","mfr_sku":"AT16144","dw_sku":"DWAT-65116","shopify_product_id":"7871000051763","action":"created","activated":true,"published":true,"sampleOnly":false,"status":"ACTIVE","batch_id":"upload-am-2026-06-30T11-40-03-557Z","ts":"2026-06-30T11:40:12.870Z"}
+{"table":"anna_french_catalog","mfr_sku":"AT16145","dw_sku":"DWAT-65117","shopify_product_id":"7871000117299","action":"created","activated":true,"published":true,"sampleOnly":false,"status":"ACTIVE","batch_id":"upload-am-2026-06-30T11-40-03-557Z","ts":"2026-06-30T11:40:16.672Z"}
+{"table":"quadrille_house_catalog","mfr_sku":"306700F-03","dw_sku":"DWQC-600132","shopify_product_id":"7871000150067","action":"created","activated":true,"published":true,"sampleOnly":true,"status":"ACTIVE","batch_id":"upload-am-2026-06-30T11-40-03-557Z","ts":"2026-06-30T11:40:20.150Z"}
+{"table":"quadrille_house_catalog","mfr_sku":"306700F-07","dw_sku":"DWQC-600133","shopify_product_id":"7871000182835","action":"created","activated":true,"published":true,"sampleOnly":true,"status":"ACTIVE","batch_id":"upload-am-2026-06-30T11-40-03-557Z","ts":"2026-06-30T11:40:23.549Z"}
+{"table":"quadrille_house_catalog","mfr_sku":"306700F-06","dw_sku":"DWQC-600134","shopify_product_id":"7871000215603","action":"created","activated":true,"published":true,"sampleOnly":true,"status":"ACTIVE","batch_id":"upload-am-2026-06-30T11-40-03-557Z","ts":"2026-06-30T11:40:27.055Z"}
+{"table":"quadrille_house_catalog","mfr_sku":"AC804L-16","dw_sku":"DWAK-700133","shopify_product_id":"7871000248371","action":"created","activated":true,"published":true,"sampleOnly":true,"status":"ACTIVE","batch_id":"upload-am-2026-06-30T11-40-03-557Z","ts":"2026-06-30T11:40:30.671Z"}
+{"table":"quadrille_house_catalog","mfr_sku":"AC804L-30","dw_sku":"DWAK-700134","shopify_product_id":"7871000281139","action":"created","activated":true,"published":true,"sampleOnly":true,"status":"ACTIVE","batch_id":"upload-am-2026-06-30T11-40-03-557Z","ts":"2026-06-30T11:40:34.097Z"}
+{"table":"quadrille_house_catalog","mfr_sku":"AC103-22","dw_sku":"DWAK-700135","shopify_product_id":"7871000379443","action":"created","activated":true,"published":true,"sampleOnly":true,"status":"ACTIVE","batch_id":"upload-am-2026-06-30T11-40-03-557Z","ts":"2026-06-30T11:40:37.693Z"}
+{"table":"quadrille_house_catalog","mfr_sku":"HC1490C-03","dw_sku":"DWHJ-900131","shopify_product_id":"7871000412211","action":"created","activated":true,"published":true,"sampleOnly":true,"status":"ACTIVE","batch_id":"upload-am-2026-06-30T11-40-03-557Z","ts":"2026-06-30T11:40:41.406Z"}
+{"table":"quadrille_house_catalog","mfr_sku":"HC1490C-04","dw_sku":"DWHJ-900132","shopify_product_id":"7871000444979","action":"created","activated":true,"published":true,"sampleOnly":true,"status":"ACTIVE","batch_id":"upload-am-2026-06-30T11-40-03-557Z","ts":"2026-06-30T11:40:44.725Z"}
+{"table":"quadrille_house_catalog","mfr_sku":"HC1490C-05","dw_sku":"DWHJ-900133","shopify_product_id":"7871000477747","action":"created","activated":true,"published":true,"sampleOnly":true,"status":"ACTIVE","batch_id":"upload-am-2026-06-30T11-40-03-557Z","ts":"2026-06-30T11:40:48.541Z"}
+{"table":"quadrille_house_catalog","mfr_sku":"2490-08SUN","dw_sku":"DWUX-800134","shopify_product_id":"7871000543283","action":"created","activated":true,"published":true,"sampleOnly":true,"status":"ACTIVE","batch_id":"upload-am-2026-06-30T11-40-03-557Z","ts":"2026-06-30T11:40:52.124Z"}
+{"table":"quadrille_house_catalog","mfr_sku":"2490SUN-81W","dw_sku":"DWUX-800135","shopify_product_id":"7871000576051","action":"created","activated":true,"published":true,"sampleOnly":true,"status":"ACTIVE","batch_id":"upload-am-2026-06-30T11-40-03-557Z","ts":"2026-06-30T11:40:55.447Z"}
+{"table":"quadrille_house_catalog","mfr_sku":"4060-07SUN","dw_sku":"DWUX-800136","shopify_product_id":"7871000608819","action":"created","activated":false,"published":false,"sampleOnly":true,"status":"DRAFT","batch_id":"upload-am-2026-06-30T11-40-03-557Z","ts":"2026-06-30T11:40:57.436Z"}
+{"table":"quadrille_house_catalog","mfr_sku":"306410F","dw_sku":"DWYP-1000131","shopify_product_id":"7871000641587","action":"created","activated":true,"published":true,"sampleOnly":true,"status":"ACTIVE","batch_id":"upload-am-2026-06-30T11-40-03-557Z","ts":"2026-06-30T11:41:00.941Z"}
+{"table":"quadrille_house_catalog","mfr_sku":"306411F","dw_sku":"DWYP-1000132","shopify_product_id":"7871000674355","action":"created","activated":true,"published":true,"sampleOnly":true,"status":"ACTIVE","batch_id":"upload-am-2026-06-30T11-40-03-557Z","ts":"2026-06-30T11:41:04.390Z"}
+{"table":"quadrille_house_catalog","mfr_sku":"306420F","dw_sku":"DWYP-1000133","shopify_product_id":"7871000707123","action":"created","activated":true,"published":true,"sampleOnly":true,"status":"ACTIVE","batch_id":"upload-am-2026-06-30T11-40-03-557Z","ts":"2026-06-30T11:41:07.858Z"}
diff --git a/shopify/staged/free-samples-function/extensions/free-samples-discount/package.json b/shopify/staged/free-samples-function/extensions/free-samples-discount/package.json
new file mode 100644
index 00000000..0bbccef3
--- /dev/null
+++ b/shopify/staged/free-samples-function/extensions/free-samples-discount/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "dw-free-samples-discount",
+ "version": "1.0.0",
+ "description": "DW Free Samples discount Function (trade unlimited, non-trade first-3-per-order, anonymous none).",
+ "type": "module",
+ "private": true,
+ "scripts": {
+ "build": "shopify app function build",
+ "typegen": "shopify app function typegen",
+ "test": "node --test"
+ },
+ "devDependencies": {
+ "@shopify/shopify_function": "^1.0.0"
+ }
+}
diff --git a/shopify/staged/free-samples-function/extensions/free-samples-discount/shopify.extension.toml b/shopify/staged/free-samples-function/extensions/free-samples-discount/shopify.extension.toml
new file mode 100644
index 00000000..96dc8f12
--- /dev/null
+++ b/shopify/staged/free-samples-function/extensions/free-samples-discount/shopify.extension.toml
@@ -0,0 +1,33 @@
+# DW Free Samples — Cart & Checkout Discount Function (2024-10 unified discount API).
+#
+# Targets cart.lines.discounts.generate.run — the current product-discount target that
+# replaced the legacy product_discounts / order_discounts split. One Function expresses
+# BOTH entitlements (trade unlimited, non-trade first-3) by branching on buyer identity.
+#
+# STAGED / UNDEPLOYED. `shopify app deploy` (gated) builds the JS to WASM via Javy and
+# uploads the Function; it does NOT activate it. Activation = creating a discount that
+# points at this Function (discountAutomaticAppCreate, see go-live checklist step F).
+
+api_version = "2024-10"
+
+[[extensions]]
+name = "DW Free Samples"
+handle = "free-samples-discount"
+type = "function"
+
+ [[extensions.targeting]]
+ target = "cart.lines.discounts.generate.run"
+ input_query = "src/run.graphql"
+ export = "run"
+
+ [extensions.build]
+ command = ""
+ path = "dist/function.wasm"
+ watch = [ "src/**.js", "src/**.graphql" ]
+
+ # Function settings UI shown in the discount-create admin screen.
+ # Lets a merchant tweak the per-order free count + sample-collection handle
+ # WITHOUT a code redeploy.
+ [extensions.ui.paths]
+ create = "/"
+ details = "/"
diff --git a/shopify/staged/free-samples-function/extensions/free-samples-discount/src/run.graphql b/shopify/staged/free-samples-function/extensions/free-samples-discount/src/run.graphql
new file mode 100644
index 00000000..3fbd2f5e
--- /dev/null
+++ b/shopify/staged/free-samples-function/extensions/free-samples-discount/src/run.graphql
@@ -0,0 +1,62 @@
+# Input query for the DW Free Samples discount Function.
+#
+# Sample identification (per Steve's note that SKU-suffix casing is inconsistent):
+# PRIMARY — variant title == "Sample" (every DW sample variant has variant title
+# "Sample" at $4.25 — verified in the staged research). Case-insensitive
+# comparison is done in run.js, so casing drift on the SKU is irrelevant.
+# OPTIONAL — a `samples` collection can additionally gate via product metafield
+# "custom.is_sample" == "true" if Steve later prefers collection-driven
+# targeting; that field is read here and honored by run.js when present.
+#
+# NOTE on collections: Shopify Function input queries CANNOT take the samples
+# collection GID from a metafield variable for an `inCollections` filter — the GID
+# would have to be hardcoded into this .graphql at deploy time, which is brittle.
+# We therefore key off the stable variant-title signal + an optional product
+# metafield, NOT live collection membership. (Documented limitation; see README.)
+#
+# Buyer identity:
+# buyerIdentity.customer.hasTrade — whether the logged-in customer carries tag "trade".
+# A NULL customer == anonymous == no discount (must register to unlock).
+#
+# discount.metafield carries merchant-tunable config (free-per-order count, trade tag,
+# sample price ceiling) so behavior is editable in admin without a code redeploy.
+
+query Input {
+ cart {
+ buyerIdentity {
+ customer {
+ id
+ hasTrade: hasTags(tags: ["trade"]) {
+ tag
+ hasTag
+ }
+ }
+ }
+ lines {
+ id
+ quantity
+ cost {
+ amountPerQuantity {
+ amount
+ }
+ }
+ merchandise {
+ __typename
+ ... on ProductVariant {
+ id
+ title
+ product {
+ isSampleMeta: metafield(namespace: "custom", key: "is_sample") {
+ value
+ }
+ }
+ }
+ }
+ }
+ }
+ discount {
+ metafield(namespace: "$app:free-samples", key: "config") {
+ value
+ }
+ }
+}
diff --git a/shopify/staged/free-samples-function/extensions/free-samples-discount/src/run.js b/shopify/staged/free-samples-function/extensions/free-samples-discount/src/run.js
new file mode 100644
index 00000000..e8991033
--- /dev/null
+++ b/shopify/staged/free-samples-function/extensions/free-samples-discount/src/run.js
@@ -0,0 +1,140 @@
+// @ts-check
+//
+// DW Free Samples — Cart & Checkout Discount Function (2024-10 unified API).
+// Target: cart.lines.discounts.generate.run
+//
+// Entitlement matrix (account-gated, frictionless — no code typed at checkout):
+// - TRADE (customer carries tag "trade") -> EVERY sample line is 100% off, unlimited.
+// - SIGNED-IN NON-TRADE -> the first N sample UNITS (default 3) are
+// 100% off, this order. (Per-order cap; see
+// README for the lifetime-cap v2 upgrade.)
+// - ANONYMOUS (no customer on the cart) -> NO discount. Signing up IS the unlock.
+//
+// Sample identification: variant title === "Sample" (case-insensitive) OR product
+// metafield custom.is_sample === "true". SKU-suffix casing is intentionally ignored.
+//
+// All money is removed via a 100% percentage discount on the targeted lines, so the
+// $4.25 sample price is fully zeroed regardless of currency/rounding.
+
+/**
+ * @typedef {import("../generated/api").CartLinesDiscountsGenerateRunResult} RunResult
+ */
+
+const EMPTY = { operations: [] };
+
+/**
+ * @param {any} input
+ * @returns {RunResult}
+ */
+export function run(input) {
+ const cart = input?.cart;
+ if (!cart || !Array.isArray(cart.lines) || cart.lines.length === 0) {
+ return EMPTY;
+ }
+
+ // ---- merchant-tunable config (discount metafield $app:free-samples/config) ----
+ let cfg = { freePerOrder: 3, tradeTag: "trade" };
+ const rawCfg = input?.discount?.metafield?.value;
+ if (rawCfg) {
+ try {
+ const parsed = JSON.parse(rawCfg);
+ if (parsed && typeof parsed === "object") {
+ if (Number.isFinite(parsed.freePerOrder)) cfg.freePerOrder = parsed.freePerOrder;
+ if (typeof parsed.tradeTag === "string" && parsed.tradeTag) cfg.tradeTag = parsed.tradeTag;
+ }
+ } catch (_) {
+ /* malformed config -> fall back to defaults; never throw inside a Function */
+ }
+ }
+
+ // ---- buyer identity gate ----
+ const customer = cart.buyerIdentity?.customer;
+ if (!customer) {
+ // Anonymous: must register. No discount.
+ return EMPTY;
+ }
+ const isTrade = (customer.hasTrade || []).some(
+ (t) => t && t.hasTag === true
+ );
+
+ // ---- find sample lines ----
+ const sampleLines = cart.lines.filter(isSampleLine);
+ if (sampleLines.length === 0) {
+ return EMPTY;
+ }
+
+ /** @type {Array<{ message: string, targets: any[], value: any }>} */
+ let candidates = [];
+
+ if (isTrade) {
+ // Trade: 100% off every sample line, unlimited.
+ candidates.push({
+ message: "Trade — samples free",
+ targets: sampleLines.map((l) => ({ cartLine: { id: l.id } })),
+ value: { percentage: { value: 100 } },
+ });
+ } else {
+ // Non-trade signed-in: first N sample UNITS free this order.
+ // Walk lines, consuming the per-order allowance unit-by-unit so a single line
+ // with quantity 5 only gets 3 of its units free when the cap is 3.
+ let remaining = Math.max(0, Math.floor(cfg.freePerOrder));
+ if (remaining === 0) return EMPTY;
+
+ for (const line of sampleLines) {
+ if (remaining <= 0) break;
+ const qty = Math.max(1, line.quantity || 1);
+ const freeUnits = Math.min(qty, remaining);
+ remaining -= freeUnits;
+
+ if (freeUnits >= qty) {
+ // Whole line free -> simple 100% percentage on the line.
+ candidates.push({
+ message: "Free sample",
+ targets: [{ cartLine: { id: line.id } }],
+ value: { percentage: { value: 100 } },
+ });
+ } else {
+ // Partial line: zero out only `freeUnits` of the line via a fixed amount
+ // equal to (perUnit * freeUnits). amountPerQuantity is the unit price.
+ const perUnit = parseFloat(line.cost?.amountPerQuantity?.amount || "0");
+ const amount = (perUnit * freeUnits).toFixed(2);
+ if (parseFloat(amount) > 0) {
+ candidates.push({
+ message: "Free sample",
+ targets: [{ cartLine: { id: line.id } }],
+ value: { fixedAmount: { amount } },
+ });
+ }
+ }
+ }
+ }
+
+ if (candidates.length === 0) return EMPTY;
+
+ return {
+ operations: [
+ {
+ productDiscountsAdd: {
+ selectionStrategy: "ALL",
+ candidates,
+ },
+ },
+ ],
+ };
+}
+
+/**
+ * A cart line is a sample if the variant title is "Sample" (case-insensitive)
+ * or the product carries custom.is_sample == "true".
+ * @param {any} line
+ * @returns {boolean}
+ */
+function isSampleLine(line) {
+ const m = line?.merchandise;
+ if (!m || m.__typename !== "ProductVariant") return false;
+ const titleIsSample =
+ typeof m.title === "string" && m.title.trim().toLowerCase() === "sample";
+ const metaIsSample =
+ (m.product?.isSampleMeta?.value || "").toString().trim().toLowerCase() === "true";
+ return titleIsSample || metaIsSample;
+}
diff --git a/shopify/staged/free-samples-function/extensions/free-samples-discount/src/run.test.js b/shopify/staged/free-samples-function/extensions/free-samples-discount/src/run.test.js
new file mode 100644
index 00000000..70d09260
--- /dev/null
+++ b/shopify/staged/free-samples-function/extensions/free-samples-discount/src/run.test.js
@@ -0,0 +1,84 @@
+// Unit tests for the DW Free Samples discount Function logic.
+// Run: node --test (from the extension dir). Pure-logic, no Shopify runtime needed.
+import { run } from "./run.js";
+import assert from "node:assert";
+import { test } from "node:test";
+
+const sampleVariant = (title = "Sample", isSampleMeta) => ({
+ __typename: "ProductVariant",
+ id: "gid://shopify/ProductVariant/1",
+ title,
+ product: { isSampleMeta: isSampleMeta ? { value: isSampleMeta } : null },
+});
+
+const line = (id, qty, variant, perUnit = "4.25") => ({
+ id: `gid://shopify/CartLine/${id}`,
+ quantity: qty,
+ cost: { amountPerQuantity: { amount: perUnit } },
+ merchandise: variant,
+});
+
+const cart = (customer, lines) => ({ cart: { buyerIdentity: { customer }, lines } });
+const trade = { id: "c1", hasTrade: [{ tag: "trade", hasTag: true }] };
+const nonTrade = { id: "c2", hasTrade: [{ tag: "trade", hasTag: false }] };
+
+test("anonymous gets nothing (must register)", () => {
+ const r = run(cart(null, [line(1, 1, sampleVariant())]));
+ assert.deepEqual(r.operations, []);
+});
+
+test("trade gets every sample line free, unlimited", () => {
+ const r = run(cart(trade, [line(1, 1, sampleVariant()), line(2, 9, sampleVariant())]));
+ const cands = r.operations[0].productDiscountsAdd.candidates;
+ assert.equal(cands.length, 1);
+ assert.equal(cands[0].targets.length, 2);
+ assert.equal(cands[0].value.percentage.value, 100);
+});
+
+test("non-trade: 4 separate sample lines -> first 3 whole lines free", () => {
+ const r = run(
+ cart(nonTrade, [
+ line(1, 1, sampleVariant()),
+ line(2, 1, sampleVariant()),
+ line(3, 1, sampleVariant()),
+ line(4, 1, sampleVariant()),
+ ])
+ );
+ const cands = r.operations[0].productDiscountsAdd.candidates;
+ assert.equal(cands.length, 3, "only 3 lines discounted");
+ for (const c of cands) assert.equal(c.value.percentage.value, 100);
+});
+
+test("non-trade: one line qty 5 -> only 3 units free (partial fixedAmount)", () => {
+ const r = run(cart(nonTrade, [line(1, 5, sampleVariant(), "4.25")]));
+ const cands = r.operations[0].productDiscountsAdd.candidates;
+ assert.equal(cands.length, 1);
+ assert.ok(cands[0].value.fixedAmount, "partial line uses fixedAmount");
+ assert.equal(cands[0].value.fixedAmount.amount, "12.75", "3 x 4.25");
+});
+
+test("non-trade ignores non-sample lines", () => {
+ const roll = { __typename: "ProductVariant", id: "v9", title: "Single Roll", product: { isSampleMeta: null } };
+ const r = run(cart(nonTrade, [line(1, 1, roll), line(2, 1, sampleVariant())]));
+ const cands = r.operations[0].productDiscountsAdd.candidates;
+ assert.equal(cands.length, 1);
+ assert.equal(cands[0].targets[0].cartLine.id, "gid://shopify/CartLine/2");
+});
+
+test("sample detection via metafield when title differs", () => {
+ const v = sampleVariant("4x6 Memo", "true");
+ const r = run(cart(nonTrade, [line(1, 1, v)]));
+ assert.equal(r.operations[0].productDiscountsAdd.candidates.length, 1);
+});
+
+test("config freePerOrder override respected", () => {
+ const c = cart(nonTrade, [line(1, 1, sampleVariant()), line(2, 1, sampleVariant())]);
+ c.discount = { metafield: { value: JSON.stringify({ freePerOrder: 1 }) } };
+ const r = run(c);
+ assert.equal(r.operations[0].productDiscountsAdd.candidates.length, 1);
+});
+
+test("no sample lines -> empty", () => {
+ const roll = { __typename: "ProductVariant", id: "v9", title: "Single Roll", product: { isSampleMeta: null } };
+ assert.deepEqual(run(cart(nonTrade, [line(1, 1, roll)])).operations, []);
+});
diff --git a/shopify/staged/free-samples-function/shopify.app.toml b/shopify/staged/free-samples-function/shopify.app.toml
new file mode 100644
index 00000000..d83b32d7
--- /dev/null
+++ b/shopify/staged/free-samples-function/shopify.app.toml
@@ -0,0 +1,26 @@
+# DW Free Samples — Shopify app that hosts the discount Function.
+# STAGED / UNDEPLOYED. Nothing here touches the live store until `shopify app deploy`
+# is run against the designer-laboratory-sandbox org (Steve-gated — see go-live checklist).
+#
+# client_id is intentionally BLANK: `shopify app deploy` / `shopify app config link`
+# fills it the first time the app is linked to the Partner org. Do NOT hand-fill.
+name = "dw-free-samples"
+handle = "dw-free-samples"
+# client_id = "" # set by `shopify app config link` at go-live
+
+application_url = "https://designer-laboratory-sandbox.myshopify.com"
+embedded = true
+
+[access_scopes]
+# The Function itself needs no scopes to RUN. read_customers/read_products are only
+# needed if/when we add the metafield lifetime-cap admin write (v2). Keep minimal for v1.
+scopes = ""
+
+[webhooks]
+api_version = "2024-10"
+
+[auth]
+redirect_urls = [ "https://designer-laboratory-sandbox.myshopify.com/auth/callback" ]
+
+[pos]
+embedded = false
← 1ae37d28 Lilycolor trade-catalog import: pricebook parser (2,543 SKUs
·
back to Designer Wallcoverings
·
free-samples phase 3: stage Shopify discount Function (trade 0af646f8 →