[object Object]

← back to Designer Wallcoverings

free-samples phase 3: stage Shopify discount Function (trade unlimited / non-trade first-3-per-order / anon none, 8 tests pass), update banner CTA to 'Sign up to get your 3 free samples', regen+verify port patch

0af646f8add9045c9fa57b36fc3263a6e991643f · 2026-06-30 05:01:25 -0700 · Steve

Files touched

Diff

commit 0af646f8add9045c9fa57b36fc3263a6e991643f
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jun 30 05:01:25 2026 -0700

    free-samples phase 3: stage Shopify discount Function (trade unlimited / non-trade first-3-per-order / anon none, 8 tests pass), update banner CTA to 'Sign up to get your 3 free samples', regen+verify port patch
---
 .../staged/free-samples-banner/banner-block.liquid | 10 ++-
 .../free-samples-banner/banner-port-to-main.patch  | 16 +++-
 .../theme.liquid.144088858675.with-banner          | 12 ++-
 shopify/staged/free-samples-function/.gitignore    |  8 ++
 shopify/staged/free-samples-function/DEPLOY.md     | 90 ++++++++++++++++++++++
 shopify/staged/free-samples-function/README.md     | 69 +++++++++++++++++
 6 files changed, 198 insertions(+), 7 deletions(-)

diff --git a/shopify/staged/free-samples-banner/banner-block.liquid b/shopify/staged/free-samples-banner/banner-block.liquid
index 11249c35..27086f21 100644
--- a/shopify/staged/free-samples-banner/banner-block.liquid
+++ b/shopify/staged/free-samples-banner/banner-block.liquid
@@ -13,6 +13,9 @@
   .dw-sample-banner .dw-sb-lead{text-transform:uppercase;letter-spacing:.14em;font-size:11px;
     font-weight:600;color:#b8865c;margin-right:8px}
   .dw-sample-banner .dw-sb-msg{display:inline}
+  .dw-sample-banner .dw-sb-sub{opacity:.62;font-size:11px;margin-left:6px}
+  .dw-sample-banner .dw-sb-sub a.dw-sb-cta{font-weight:500}
+  @media(max-width:600px){.dw-sample-banner .dw-sb-sub{display:block;margin:3px 0 0}}
   .dw-sample-banner .dw-sb-x{position:absolute;right:14px;top:50%;transform:translateY(-50%);
     background:none;border:0;color:#faf7f2;opacity:.6;font-size:18px;line-height:1;cursor:pointer;
     padding:4px 8px}
@@ -25,9 +28,14 @@
   {%- if dw_is_trade -%}
     <span class="dw-sb-lead">To the Trade</span>
     <span class="dw-sb-msg">Your samples are always free, with no limit. <a class="dw-sb-cta" href="/collections/all">Browse the collection &rsaquo;</a></span>
+  {%- elsif customer -%}
+    {%- comment -%} signed-in non-trade: offer is already unlocked, auto-applies at checkout {%- endcomment -%}
+    <span class="dw-sb-lead">Welcome</span>
+    <span class="dw-sb-msg">Your first 3 samples are free &mdash; added at checkout, no code needed. <a class="dw-sb-cta" href="/collections/all">Order your free samples &rsaquo;</a> <span class="dw-sb-sub">Designers &amp; trade? <a class="dw-sb-cta" href="/account">Unlock unlimited &rsaquo;</a></span></span>
   {%- else -%}
+    {%- comment -%} anonymous: signing up IS the unlock (Function auto-applies on login) {%- endcomment -%}
     <span class="dw-sb-lead">Welcome</span>
-    <span class="dw-sb-msg">Your first 3 samples are free. {% unless customer %}<a class="dw-sb-cta" href="/account/register">Designers &amp; trade &mdash; sign up for unlimited free samples &rsaquo;</a>{% else %}<a class="dw-sb-cta" href="/collections/all">Order your free samples &rsaquo;</a>{% endunless %}</span>
+    <span class="dw-sb-msg"><a class="dw-sb-cta" href="/account/register">Sign up to get your 3 free samples &rsaquo;</a> <span class="dw-sb-sub">Designers &amp; trade get unlimited free samples.</span></span>
   {%- endif -%}
   <button class="dw-sb-x" type="button" aria-label="Dismiss" onclick="(function(b){try{localStorage.setItem('dw_sample_banner_dismissed','1');}catch(e){}b.parentNode.classList.add('dw-sb-hidden');})(this)">&times;</button>
 </div>
diff --git a/shopify/staged/free-samples-banner/banner-port-to-main.patch b/shopify/staged/free-samples-banner/banner-port-to-main.patch
index 42dfa131..7034f912 100644
--- a/shopify/staged/free-samples-banner/banner-port-to-main.patch
+++ b/shopify/staged/free-samples-banner/banner-port-to-main.patch
@@ -1,12 +1,12 @@
 --- theme.liquid.144070803507.original	2026-06-30 04:17:15
-+++ theme.liquid.144088858675.with-banner	2026-06-30 04:17:15
-@@ -529,7 +529,45 @@
++++ theme.liquid.144088858675.with-banner	2026-06-30 05:00:28
+@@ -529,7 +529,53 @@
        <p style="font-size:16px;color:#666;max-width:500px;">We apologize for the inconvenience, but this store does not currently serve customers in your region.</p>
      </div>
      <style>body{overflow:hidden !important;}</style>
 +  {%- endif -%}
 +
-+  {%- comment -%} DW-SAMPLES-BANNER v1 — staged free-samples announcement (dismissible, trade-aware). NEVER touches live unless this theme is published. {%- endcomment -%}
++{%- comment -%} DW-SAMPLES-BANNER v1 — staged free-samples announcement (dismissible, trade-aware). NEVER touches live unless this theme is published. {%- endcomment -%}
 +<!-- DW-SAMPLES-BANNER v1 -->
 +{%- assign dw_is_trade = false -%}
 +{%- if customer and customer.tags contains 'trade' -%}{%- assign dw_is_trade = true -%}{%- endif -%}
@@ -21,6 +21,9 @@
 +  .dw-sample-banner .dw-sb-lead{text-transform:uppercase;letter-spacing:.14em;font-size:11px;
 +    font-weight:600;color:#b8865c;margin-right:8px}
 +  .dw-sample-banner .dw-sb-msg{display:inline}
++  .dw-sample-banner .dw-sb-sub{opacity:.62;font-size:11px;margin-left:6px}
++  .dw-sample-banner .dw-sb-sub a.dw-sb-cta{font-weight:500}
++  @media(max-width:600px){.dw-sample-banner .dw-sb-sub{display:block;margin:3px 0 0}}
 +  .dw-sample-banner .dw-sb-x{position:absolute;right:14px;top:50%;transform:translateY(-50%);
 +    background:none;border:0;color:#faf7f2;opacity:.6;font-size:18px;line-height:1;cursor:pointer;
 +    padding:4px 8px}
@@ -33,9 +36,14 @@
 +  {%- if dw_is_trade -%}
 +    <span class="dw-sb-lead">To the Trade</span>
 +    <span class="dw-sb-msg">Your samples are always free, with no limit. <a class="dw-sb-cta" href="/collections/all">Browse the collection &rsaquo;</a></span>
++  {%- elsif customer -%}
++    {%- comment -%} signed-in non-trade: offer is already unlocked, auto-applies at checkout {%- endcomment -%}
++    <span class="dw-sb-lead">Welcome</span>
++    <span class="dw-sb-msg">Your first 3 samples are free &mdash; added at checkout, no code needed. <a class="dw-sb-cta" href="/collections/all">Order your free samples &rsaquo;</a> <span class="dw-sb-sub">Designers &amp; trade? <a class="dw-sb-cta" href="/account">Unlock unlimited &rsaquo;</a></span></span>
 +  {%- else -%}
++    {%- comment -%} anonymous: signing up IS the unlock (Function auto-applies on login) {%- endcomment -%}
 +    <span class="dw-sb-lead">Welcome</span>
-+    <span class="dw-sb-msg">Your first 3 samples are free. {% unless customer %}<a class="dw-sb-cta" href="/account/register">Designers &amp; trade &mdash; sign up for unlimited free samples &rsaquo;</a>{% else %}<a class="dw-sb-cta" href="/collections/all">Order your free samples &rsaquo;</a>{% endunless %}</span>
++    <span class="dw-sb-msg"><a class="dw-sb-cta" href="/account/register">Sign up to get your 3 free samples &rsaquo;</a> <span class="dw-sb-sub">Designers &amp; trade get unlimited free samples.</span></span>
    {%- endif -%}
 +  <button class="dw-sb-x" type="button" aria-label="Dismiss" onclick="(function(b){try{localStorage.setItem('dw_sample_banner_dismissed','1');}catch(e){}b.parentNode.classList.add('dw-sb-hidden');})(this)">&times;</button>
 +</div>
diff --git a/shopify/staged/free-samples-banner/theme.liquid.144088858675.with-banner b/shopify/staged/free-samples-banner/theme.liquid.144088858675.with-banner
index 4a37ee61..81f8c22d 100644
--- a/shopify/staged/free-samples-banner/theme.liquid.144088858675.with-banner
+++ b/shopify/staged/free-samples-banner/theme.liquid.144088858675.with-banner
@@ -531,7 +531,7 @@
     <style>body{overflow:hidden !important;}</style>
   {%- endif -%}
 
-  {%- comment -%} DW-SAMPLES-BANNER v1 — staged free-samples announcement (dismissible, trade-aware). NEVER touches live unless this theme is published. {%- endcomment -%}
+{%- comment -%} DW-SAMPLES-BANNER v1 — staged free-samples announcement (dismissible, trade-aware). NEVER touches live unless this theme is published. {%- endcomment -%}
 <!-- DW-SAMPLES-BANNER v1 -->
 {%- assign dw_is_trade = false -%}
 {%- if customer and customer.tags contains 'trade' -%}{%- assign dw_is_trade = true -%}{%- endif -%}
@@ -546,6 +546,9 @@
   .dw-sample-banner .dw-sb-lead{text-transform:uppercase;letter-spacing:.14em;font-size:11px;
     font-weight:600;color:#b8865c;margin-right:8px}
   .dw-sample-banner .dw-sb-msg{display:inline}
+  .dw-sample-banner .dw-sb-sub{opacity:.62;font-size:11px;margin-left:6px}
+  .dw-sample-banner .dw-sb-sub a.dw-sb-cta{font-weight:500}
+  @media(max-width:600px){.dw-sample-banner .dw-sb-sub{display:block;margin:3px 0 0}}
   .dw-sample-banner .dw-sb-x{position:absolute;right:14px;top:50%;transform:translateY(-50%);
     background:none;border:0;color:#faf7f2;opacity:.6;font-size:18px;line-height:1;cursor:pointer;
     padding:4px 8px}
@@ -558,9 +561,14 @@
   {%- if dw_is_trade -%}
     <span class="dw-sb-lead">To the Trade</span>
     <span class="dw-sb-msg">Your samples are always free, with no limit. <a class="dw-sb-cta" href="/collections/all">Browse the collection &rsaquo;</a></span>
+  {%- elsif customer -%}
+    {%- comment -%} signed-in non-trade: offer is already unlocked, auto-applies at checkout {%- endcomment -%}
+    <span class="dw-sb-lead">Welcome</span>
+    <span class="dw-sb-msg">Your first 3 samples are free &mdash; added at checkout, no code needed. <a class="dw-sb-cta" href="/collections/all">Order your free samples &rsaquo;</a> <span class="dw-sb-sub">Designers &amp; trade? <a class="dw-sb-cta" href="/account">Unlock unlimited &rsaquo;</a></span></span>
   {%- else -%}
+    {%- comment -%} anonymous: signing up IS the unlock (Function auto-applies on login) {%- endcomment -%}
     <span class="dw-sb-lead">Welcome</span>
-    <span class="dw-sb-msg">Your first 3 samples are free. {% unless customer %}<a class="dw-sb-cta" href="/account/register">Designers &amp; trade &mdash; sign up for unlimited free samples &rsaquo;</a>{% else %}<a class="dw-sb-cta" href="/collections/all">Order your free samples &rsaquo;</a>{% endunless %}</span>
+    <span class="dw-sb-msg"><a class="dw-sb-cta" href="/account/register">Sign up to get your 3 free samples &rsaquo;</a> <span class="dw-sb-sub">Designers &amp; trade get unlimited free samples.</span></span>
   {%- endif -%}
   <button class="dw-sb-x" type="button" aria-label="Dismiss" onclick="(function(b){try{localStorage.setItem('dw_sample_banner_dismissed','1');}catch(e){}b.parentNode.classList.add('dw-sb-hidden');})(this)">&times;</button>
 </div>
diff --git a/shopify/staged/free-samples-function/.gitignore b/shopify/staged/free-samples-function/.gitignore
new file mode 100644
index 00000000..7f5f2f41
--- /dev/null
+++ b/shopify/staged/free-samples-function/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+dist/
+generated/
+*.wasm
+.shopify/
+.env*
+*.log
+.DS_Store
diff --git a/shopify/staged/free-samples-function/DEPLOY.md b/shopify/staged/free-samples-function/DEPLOY.md
new file mode 100644
index 00000000..28087a2a
--- /dev/null
+++ b/shopify/staged/free-samples-function/DEPLOY.md
@@ -0,0 +1,90 @@
+# DW Free Samples Function — GO-LIVE DEPLOY (gated; touches the live store)
+
+> Every command below is a GATED action. Do NOT run any of these until Steve says go.
+> `shopify app deploy` uploads the Function to the live store's app; creating the
+> discount object activates it on real carts. Order matters.
+
+Store: `designer-laboratory-sandbox.myshopify.com` · API: 2024-10
+Run from: `~/Projects/Designer-Wallcoverings/shopify/staged/free-samples-function`
+
+---
+
+## Step 0 — one-time: auth + link the app to the Partner org (interactive)
+```sh
+cd ~/Projects/Designer-Wallcoverings/shopify/staged/free-samples-function
+shopify auth logout            # optional, ensures correct account
+shopify app config link        # pick the DW Partner org + designer-laboratory-sandbox
+# ^ this writes client_id into shopify.app.toml. Commit that change.
+```
+
+## Step 1 — install the build toolchain + run tests (local, $0)
+```sh
+cd extensions/free-samples-discount
+npm install
+node --test                    # expect: 8 pass / 0 fail
+shopify app function typegen    # regenerates ../generated/api types from run.graphql
+cd ../..
+```
+
+## Step 2 — BUILD the Function to WASM (local, $0)
+```sh
+shopify app function build      # Javy compiles run.js -> dist/function.wasm
+# OR build the whole app:  shopify app build
+```
+
+## Step 3 — DEPLOY the Function extension (GATED — uploads to the live app)
+```sh
+shopify app deploy
+# Creates a new app VERSION carrying the Function. This makes the Function AVAILABLE
+# on the store, but it is NOT YET applied to any cart — a discount object must point
+# at it (Step 5). Until then, zero customer impact.
+```
+
+## Step 4 — get the Function ID (read-only)
+After deploy, find the Function's ID (needed for the discount create):
+```sh
+# Admin GraphQL (DRAFT token has the scope to read this):
+#   query { shopifyFunctions(first: 25) { nodes { id title apiType } } }
+# Note the `id` whose title == "DW Free Samples" / apiType discount.
+```
+
+## Step 5 — ACTIVATE: create the automatic discount that runs the Function (GATED — money surface)
+Use `discountAutomaticAppCreate` with the Function ID. Set `startsAt` in the FUTURE
+first so it is staged-inactive; flip to now only when the banner is live + reviewed.
+```graphql
+mutation {
+  discountAutomaticAppCreate(automaticAppDiscount: {
+    title: "DW Free Samples (auto)",
+    functionId: "REPLACE_WITH_FUNCTION_ID",
+    startsAt: "2099-01-01T00:00:00Z",   # future = inactive until go-live
+    combinesWith: { orderDiscounts: true, productDiscounts: true, shippingDiscounts: true }
+    # metafields: optional $app:free-samples/config JSON to override freePerOrder/tradeTag
+  }) {
+    automaticAppDiscount { discountId title status }
+    userErrors { field message }
+  }
+}
+```
+Token: `SHOPIFY_DRAFT_TOKEN` (has `write_discounts`). Endpoint:
+`https://designer-laboratory-sandbox.myshopify.com/admin/api/2024-10/graphql.json`.
+
+## Step 6 — GO LIVE: flip the discount active
+```graphql
+mutation {
+  discountAutomaticAppUpdate(
+    id: "REPLACE_WITH_DISCOUNT_ID",
+    automaticAppDiscount: { startsAt: "NOW_ISO8601" }
+  ) { userErrors { field message } }
+}
+```
+
+## VERIFY (after go-live)
+1. Anonymous cart with a sample line -> NO discount at checkout.
+2. Sign up a fresh non-trade test account, add 4 sample lines -> first 3 free, 4th paid.
+3. Log in as a `trade` test customer, add 5 samples -> all 5 free.
+
+## ROLLBACK
+- Deactivate: `discountAutomaticAppUpdate` with `startsAt` in the future, OR
+  `discountAutomaticDelete(id:...)`.
+- Remove the Function: `shopify app deploy` a version without the extension, or
+  uninstall the app. The Function alone (no discount pointing at it) has zero effect.
diff --git a/shopify/staged/free-samples-function/README.md b/shopify/staged/free-samples-function/README.md
new file mode 100644
index 00000000..cee915be
--- /dev/null
+++ b/shopify/staged/free-samples-function/README.md
@@ -0,0 +1,69 @@
+# DW Free Samples — Shopify Discount Function (STAGED, UNDEPLOYED)
+
+Frictionless, account-gated free-samples offer for designer-laboratory-sandbox.
+**Nothing here is deployed or activated.** Building it is local; `shopify app deploy`
+(gated) uploads the Function; a discount object (gated) activates it.
+
+## What it does (entitlement matrix)
+
+| Buyer | Outcome | Mechanism |
+|---|---|---|
+| **Anonymous** (not signed in) | **No discount** — must register to unlock | Function returns `{operations:[]}` when `cart.buyerIdentity.customer` is null |
+| **Signed-in non-trade** | **First 3 sample UNITS free** this order, no code | 100% off whole sample lines + partial `fixedAmount` on the line that straddles the cap |
+| **Trade** (`tag:trade`) | **Every sample line free, unlimited** | 100% off all sample lines |
+
+The act of **signing up is the unlock** (DTD verdict A, 2/2): the moment a non-trade
+customer is logged in, their first 3 sample lines are auto-zeroed at checkout. No code
+typed, no email sent, no gift card.
+
+## Sample identification
+- PRIMARY: variant title == `"Sample"` (case-insensitive). Every DW sample variant is
+  `{DW_SKU}-Sample` @ $4.25 with variant title "Sample". SKU-suffix casing drift
+  (`-Sample`/`-sample`/`-SAMPLE`) is irrelevant — we match on the variant *title*.
+- OPTIONAL: product metafield `custom.is_sample == "true"` (also honored). Use this if
+  you ever have a sample that isn't titled "Sample".
+
+## API
+- 2024-10 unified **Cart & Checkout Discount** Function.
+- Target: `cart.lines.discounts.generate.run`, export `run`.
+- Returns `productDiscountsAdd` with `selectionStrategy: ALL` + candidates.
+- Pure JS, compiled to WASM by Javy via `shopify app function build` — no Rust toolchain.
+
+## Merchant-tunable config
+The discount that points at this Function carries a metafield
+`$app:free-samples/config` (JSON), letting admin tune behavior with no redeploy:
+```json
+{ "freePerOrder": 3, "tradeTag": "trade" }
+```
+Defaults baked in (`freePerOrder: 3`, `tradeTag: "trade"`) if the metafield is absent.
+
+## KNOWN LIMITATION — lifetime cap (read this)
+A Shopify Function sees **one cart at a time**. It therefore enforces **"3 free per
+ORDER"**, NOT **"3 free ever"**. A signed-in non-trade customer could place multiple
+orders and get 3 free samples each time. This is acceptable for v1 because:
+1. the account gate already filters drive-by/anonymous abuse, and
+2. $4.25 sample chips are low-stakes.
+
+### v2 lifetime-cap upgrade (documented, not built)
+To enforce a true per-customer lifetime cap of 3:
+1. Add customer metafield `custom.free_samples_used` (number_integer, default 0).
+2. Add it to `run.graphql` input: `cart.buyerIdentity.customer.metafield(namespace:"custom", key:"free_samples_used")`.
+3. In `run.js`, compute `remaining = max(0, freeLifetime - used)` instead of a flat per-order count.
+4. Add a post-purchase automation (Shopify Flow on `orders/create`, OR a webhook into a
+   small DW service) that increments `custom.free_samples_used` by the number of sample
+   units that were discounted on that order. (The increment must be server-side — a
+   Function cannot write metafields.)
+This v2 is a separate gated build (needs `write_customers` + a Flow or webhook worker).
+
+## Files
+- `shopify.app.toml` — app config (client_id filled at `config link` time).
+- `extensions/free-samples-discount/shopify.extension.toml` — function extension config.
+- `extensions/free-samples-discount/src/run.graphql` — input query.
+- `extensions/free-samples-discount/src/run.js` — the discount logic.
+- `extensions/free-samples-discount/src/run.test.js` — 8 unit tests (all pass, `node --test`).
+- `DEPLOY.md` — exact, ordered deploy + activate commands (gated).
+
+## Quick test (local, $0)
+```sh
+cd extensions/free-samples-discount && node --test
+```

← fef16d47 auto-save: 2026-06-30T04:58:21 (6 files) — pending-approval/  ·  back to Designer Wallcoverings  ·  auto-save: 2026-06-30T05:28:32 (4 files) — onboarding/sanget 700c3531 →