@runwell/shopify-toolkit 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,8 @@
10
10
  {%- if settings.bundle_system__surface_5_pdp_banner_enabled == false -%}
11
11
  {%- comment -%} Tenant has disabled this surface. {%- endcomment -%}
12
12
  {%- else -%}
13
- {%- assign bundle_index = shop.metaobjects.bundle_index.entries.value -%}
13
+ {%- assign bundle_index_instance = shop.metaobjects.bundle_index.values | first -%}
14
+ {%- assign bundle_index = bundle_index_instance.entries.value -%}
14
15
  {%- assign matching_handles = '' -%}
15
16
  {%- if bundle_index and bundle_index.products -%}
16
17
  {%- assign matching_handles = bundle_index.products[product.handle] -%}
@@ -12,7 +12,8 @@
12
12
  {%- if settings.bundle_system__surface_2_pdp_pairs_with_enabled == false -%}
13
13
  {%- comment -%} Tenant has disabled this surface. {%- endcomment -%}
14
14
  {%- else -%}
15
- {%- assign bundle_index = shop.metaobjects.bundle_index.entries.value -%}
15
+ {%- assign bundle_index_instance = shop.metaobjects.bundle_index.values | first -%}
16
+ {%- assign bundle_index = bundle_index_instance.entries.value -%}
16
17
  {%- assign matching_handles = '' -%}
17
18
  {%- if bundle_index and bundle_index.products -%}
18
19
  {%- assign matching_handles = bundle_index.products[product.handle] -%}
@@ -112,6 +112,8 @@
112
112
  "presets": [
113
113
  { "name": "Bundle PDP", "category": "Runwell" }
114
114
  ],
115
- "templates": ["product.bundle"]
115
+ "enabled_on": {
116
+ "templates": ["product"]
117
+ }
116
118
  }
117
119
  {% endschema %}
@@ -0,0 +1,318 @@
1
+ {%- comment -%}
2
+ runwell-bundle-system: runwell-bundle-quantity-builder.liquid.
3
+
4
+ Mode A bundle PDP surface. Renders a product image gallery beside a
5
+ quantity-tier picker, sale-prefix headline, FOMO countdown + scarcity,
6
+ rating row, trust badges row, and an ATC that can append a free gift
7
+ product when the selected tier opts into one.
8
+
9
+ Everything bundle-specific is read from `product.metafields.runwell.*`:
10
+ bundle_quantity_tiers [{qty, discount_pct, label?, popular?,
11
+ free_shipping?, free_gift?}]
12
+ bundle_sale_prefix "Spring Sculpting Sale"
13
+ bundle_fomo_mode none | discount | scarcity | both
14
+ bundle_fomo_cycle_days number (rolling deadline cycle)
15
+ bundle_fomo_stock_count number
16
+ bundle_rating_score "4.8/5"
17
+ bundle_rating_count "2,400+"
18
+ bundle_free_gift_handle product handle (real $0 product); the section
19
+ adds a hidden form input + the ATC JS appends
20
+ it as a second /cart/add line when a free_gift
21
+ tier is selected.
22
+
23
+ Section settings cover what's tenant/page level (color scheme, padding,
24
+ trust badge copy, toggles). Per-tier data is metafield-driven so admin
25
+ changes flow without re-deploying the theme.
26
+
27
+ Replaces the legacy modules/bundle-builder/sections/runwell-bundle-builder
28
+ section. CSS class prefix kept stable (.runwell-bundle-quantity-builder)
29
+ with the same visual structure to preserve tenant calibration.
30
+ {%- endcomment -%}
31
+
32
+ {{ 'runwell-bundle-quantity-builder.css' | asset_url | stylesheet_tag }}
33
+
34
+ {%- assign product = section.settings.product -%}
35
+ {%- if product == blank -%}
36
+ <div class="runwell-bundle-quantity-builder runwell-bundle-quantity-builder--empty page-width">
37
+ <p>Select a product in the section editor to render the bundle builder.</p>
38
+ </div>
39
+ {%- else -%}
40
+
41
+ {%- assign mf = product.metafields.runwell -%}
42
+ {%- assign tiers = mf.bundle_quantity_tiers.value -%}
43
+ {%- assign sale_prefix = mf.bundle_sale_prefix.value | default: section.settings.sale_prefix -%}
44
+ {%- assign fomo_mode = mf.bundle_fomo_mode.value | default: 'none' -%}
45
+ {%- assign fomo_cycle_days = mf.bundle_fomo_cycle_days.value | default: 30 -%}
46
+ {%- assign fomo_stock_count = mf.bundle_fomo_stock_count.value | default: 0 -%}
47
+ {%- assign rating_score = mf.bundle_rating_score.value -%}
48
+ {%- assign rating_count = mf.bundle_rating_count.value -%}
49
+ {%- assign free_gift_handle = mf.bundle_free_gift_handle.value -%}
50
+ {%- assign free_gift_product = blank -%}
51
+ {%- if free_gift_handle != blank -%}
52
+ {%- assign free_gift_product = all_products[free_gift_handle] -%}
53
+ {%- endif -%}
54
+
55
+ <section
56
+ class="runwell-bundle-quantity-builder color-{{ section.settings.color_scheme }}"
57
+ style="padding-top: {{ section.settings.padding_top }}px; padding-bottom: {{ section.settings.padding_bottom }}px;"
58
+ data-runwell-bundle-quantity-builder
59
+ data-product-handle="{{ product.handle }}"
60
+ {%- if free_gift_product != blank %} data-free-gift-variant-id="{{ free_gift_product.selected_or_first_available_variant.id }}"{%- endif -%}
61
+ >
62
+ <div class="page-width">
63
+ <div class="runwell-bundle-quantity-builder__grid">
64
+
65
+ {%- comment -%} Gallery: prefers product.media; falls back to featured_image. {%- endcomment -%}
66
+ <div class="runwell-bundle-quantity-builder__media">
67
+ {%- assign media_items = product.media -%}
68
+ {%- if media_items.size > 0 -%}
69
+ <div class="runwell-bundle-quantity-builder__slideshow" data-slideshow>
70
+ {%- for media in media_items -%}
71
+ <div
72
+ class="runwell-bundle-quantity-builder__slide {% if forloop.first %}runwell-bundle-quantity-builder__slide--active{% endif %}"
73
+ data-slide="{{ forloop.index0 }}"
74
+ >
75
+ {{ media | image_url: width: 1200 | image_tag:
76
+ class: 'runwell-bundle-quantity-builder__img',
77
+ loading: forloop.first | default: 'eager',
78
+ sizes: '(min-width: 990px) 50vw, 100vw',
79
+ alt: media.alt | default: product.title
80
+ }}
81
+ </div>
82
+ {%- endfor -%}
83
+ </div>
84
+ {%- if media_items.size > 1 -%}
85
+ <div class="runwell-bundle-quantity-builder__thumbnails" data-thumbnails>
86
+ {%- for media in media_items -%}
87
+ <button
88
+ class="runwell-bundle-quantity-builder__thumb {% if forloop.first %}runwell-bundle-quantity-builder__thumb--active{% endif %}"
89
+ type="button"
90
+ data-thumb="{{ forloop.index0 }}"
91
+ aria-label="View image {{ forloop.index }}"
92
+ >
93
+ {{ media | image_url: width: 160, height: 160, crop: 'center' | image_tag: loading: 'lazy', alt: '' }}
94
+ </button>
95
+ {%- endfor -%}
96
+ </div>
97
+ {%- endif -%}
98
+ {%- elsif product.featured_image -%}
99
+ <div class="runwell-bundle-quantity-builder__slideshow">
100
+ <div class="runwell-bundle-quantity-builder__slide runwell-bundle-quantity-builder__slide--active">
101
+ {{ product.featured_image | image_url: width: 1200 | image_tag:
102
+ class: 'runwell-bundle-quantity-builder__img',
103
+ loading: 'eager',
104
+ alt: product.title
105
+ }}
106
+ </div>
107
+ </div>
108
+ {%- else -%}
109
+ <div class="runwell-bundle-quantity-builder__slideshow">
110
+ <div class="runwell-bundle-quantity-builder__slide runwell-bundle-quantity-builder__slide--active">
111
+ <div class="runwell-bundle-quantity-builder__img runwell-bundle-quantity-builder__img--placeholder" aria-label="No image"></div>
112
+ </div>
113
+ </div>
114
+ {%- endif -%}
115
+ </div>
116
+
117
+ <div class="runwell-bundle-quantity-builder__info">
118
+
119
+ {%- if section.settings.show_rating and rating_score != blank -%}
120
+ <div class="runwell-bundle-quantity-builder__rating">
121
+ <span class="runwell-bundle-quantity-builder__stars" aria-hidden="true">&#9733;&#9733;&#9733;&#9733;&#9733;</span>
122
+ <span class="runwell-bundle-quantity-builder__rating-text">
123
+ Rated {{ rating_score }}{%- if rating_count != blank %} ({{ rating_count }} Reviews){%- endif -%}
124
+ </span>
125
+ </div>
126
+ {%- endif -%}
127
+
128
+ <h2 class="runwell-bundle-quantity-builder__title h1">{{ product.title }}</h2>
129
+
130
+ {%- if fomo_mode != 'none' or sale_prefix != blank -%}
131
+ {%- assign show_scarcity = false -%}
132
+ {%- if fomo_mode == 'scarcity' or fomo_mode == 'both' -%}
133
+ {%- if fomo_stock_count > 0 -%}{%- assign show_scarcity = true -%}{%- endif -%}
134
+ {%- endif -%}
135
+
136
+ {%- assign sale_text = sale_prefix -%}
137
+ {%- if fomo_mode == 'discount' or fomo_mode == 'both' -%}
138
+ {%- assign now_epoch = 'now' | date: '%s' | plus: 0 -%}
139
+ {%- assign cycle_seconds = fomo_cycle_days | times: 86400 -%}
140
+ {%- assign cycle_position = now_epoch | modulo: cycle_seconds -%}
141
+ {%- assign remaining_seconds = cycle_seconds | minus: cycle_position -%}
142
+ {%- assign end_epoch = now_epoch | plus: remaining_seconds -%}
143
+ {%- assign end_date = end_epoch | date: '%B %e' | strip -%}
144
+ {%- if sale_prefix != blank -%}
145
+ {%- capture sale_text -%}{{ sale_prefix }}: Ends {{ end_date }}{%- endcapture -%}
146
+ {%- else -%}
147
+ {%- capture sale_text -%}Ends {{ end_date }}{%- endcapture -%}
148
+ {%- endif -%}
149
+ {%- endif -%}
150
+
151
+ {%- if sale_text != blank -%}
152
+ <div class="runwell-bundle-quantity-builder__sale-heading">
153
+ <span class="runwell-bundle-quantity-builder__sale-line"></span>
154
+ <span class="runwell-bundle-quantity-builder__sale-text">{{ sale_text }}</span>
155
+ <span class="runwell-bundle-quantity-builder__sale-line"></span>
156
+ </div>
157
+ {%- endif -%}
158
+
159
+ {%- if show_scarcity -%}
160
+ <div class="runwell-bundle-quantity-builder__scarcity">
161
+ <span class="runwell-bundle-quantity-builder__scarcity-dot"></span>
162
+ Only {{ fomo_stock_count }} left in stock
163
+ </div>
164
+ {%- endif -%}
165
+ {%- endif -%}
166
+
167
+ {%- if tiers and tiers.size > 0 -%}
168
+ {%- assign has_popular = false -%}
169
+ {%- for t in tiers -%}
170
+ {%- if t.popular -%}{%- assign has_popular = true -%}{%- endif -%}
171
+ {%- endfor -%}
172
+
173
+ <form
174
+ action="{{ routes.cart_add_url }}"
175
+ method="post"
176
+ enctype="multipart/form-data"
177
+ class="runwell-bundle-quantity-builder__form"
178
+ data-runwell-bundle-form
179
+ >
180
+ <input type="hidden" name="form_type" value="product">
181
+ <input type="hidden" name="utf8" value="✓">
182
+ <input type="hidden" name="id" value="{{ product.selected_or_first_available_variant.id }}">
183
+
184
+ <fieldset class="runwell-bundle-quantity-builder__options" aria-label="Choose quantity">
185
+ <legend class="visually-hidden">Choose quantity</legend>
186
+ {%- for t in tiers -%}
187
+ {%- assign is_default = false -%}
188
+ {%- if has_popular and t.popular -%}
189
+ {%- assign is_default = true -%}
190
+ {%- elsif has_popular == false and forloop.first -%}
191
+ {%- assign is_default = true -%}
192
+ {%- endif -%}
193
+
194
+ {%- assign tier_label = t.label -%}
195
+ {%- if tier_label == blank -%}
196
+ {%- capture tier_label -%}{{ t.qty }}x {{ product.title }}{%- endcapture -%}
197
+ {%- endif -%}
198
+
199
+ {%- assign discount_amount = product.price | times: t.discount_pct | divided_by: 100 -%}
200
+ {%- assign per_unit_price = product.price | minus: discount_amount -%}
201
+ {%- assign total_price = per_unit_price | times: t.qty -%}
202
+ {%- assign full_price = product.price | times: t.qty -%}
203
+ {%- assign savings = full_price | minus: total_price -%}
204
+
205
+ <label
206
+ class="runwell-bundle-quantity-builder__option {% if t.popular %}runwell-bundle-quantity-builder__option--popular{% endif %} {% if is_default %}runwell-bundle-quantity-builder__option--selected{% endif %}"
207
+ data-tier-qty="{{ t.qty }}"
208
+ data-tier-free-gift="{{ t.free_gift | default: false }}"
209
+ >
210
+ {%- if t.popular -%}
211
+ <span class="runwell-bundle-quantity-builder__popular-badge">MOST POPULAR</span>
212
+ {%- endif -%}
213
+ <input
214
+ type="radio"
215
+ name="quantity"
216
+ value="{{ t.qty }}"
217
+ class="runwell-bundle-quantity-builder__option-input"
218
+ data-runwell-tier-radio
219
+ {% if is_default %}checked{% endif %}
220
+ >
221
+ <span class="runwell-bundle-quantity-builder__option-radio" aria-hidden="true"></span>
222
+ <span class="runwell-bundle-quantity-builder__option-content">
223
+ <span class="runwell-bundle-quantity-builder__option-header">
224
+ <span class="runwell-bundle-quantity-builder__option-title">{{ tier_label }}</span>
225
+ <span class="runwell-bundle-quantity-builder__option-pricing">
226
+ <span class="runwell-bundle-quantity-builder__option-price">{{ total_price | money_without_trailing_zeros }}</span>
227
+ {%- if savings > 0 -%}
228
+ <span class="runwell-bundle-quantity-builder__option-compare">{{ full_price | money_without_trailing_zeros }}</span>
229
+ {%- endif -%}
230
+ </span>
231
+ </span>
232
+ {%- if savings > 0 or t.free_shipping -%}
233
+ <span class="runwell-bundle-quantity-builder__option-badges">
234
+ {%- if savings > 0 -%}
235
+ <span class="runwell-bundle-quantity-builder__badge runwell-bundle-quantity-builder__badge--save">SAVE {{ savings | money_without_trailing_zeros }}</span>
236
+ {%- endif -%}
237
+ {%- if t.free_shipping -%}
238
+ <span class="runwell-bundle-quantity-builder__badge runwell-bundle-quantity-builder__badge--shipping">FREE SHIPPING</span>
239
+ {%- endif -%}
240
+ </span>
241
+ {%- endif -%}
242
+ {%- if t.free_gift and free_gift_product != blank -%}
243
+ <span class="runwell-bundle-quantity-builder__free-gift">
244
+ <span class="runwell-bundle-quantity-builder__gift-icon" aria-hidden="true">&#127873;</span>
245
+ <span>+ Free {{ free_gift_product.title }} for a limited time.</span>
246
+ </span>
247
+ {%- endif -%}
248
+ </span>
249
+ </label>
250
+ {%- endfor -%}
251
+ </fieldset>
252
+
253
+ <button
254
+ type="submit"
255
+ class="runwell-bundle-quantity-builder__atc button button--full-width"
256
+ data-runwell-bundle-atc
257
+ {% if product.selected_or_first_available_variant.available == false %}disabled{% endif %}
258
+ >
259
+ <span class="runwell-bundle-quantity-builder__atc-text">
260
+ {% if product.selected_or_first_available_variant.available %}ADD TO CART{% else %}SOLD OUT{% endif %}
261
+ </span>
262
+ <span class="runwell-bundle-quantity-builder__atc-loading" style="display:none;" aria-hidden="true">Adding…</span>
263
+ </button>
264
+ </form>
265
+ {%- else -%}
266
+ <p class="runwell-bundle-quantity-builder__empty">
267
+ No quantity tiers configured. Set
268
+ <code>runwell.bundle_quantity_tiers</code> on this product
269
+ to enable the bundle builder.
270
+ </p>
271
+ {%- endif -%}
272
+
273
+ {%- if section.settings.show_trust_badges -%}
274
+ <div class="runwell-bundle-quantity-builder__trust">
275
+ {%- if section.settings.trust_1 != blank -%}<span>&#9989; {{ section.settings.trust_1 }}</span>{%- endif -%}
276
+ {%- if section.settings.trust_2 != blank -%}<span>&#9989; {{ section.settings.trust_2 }}</span>{%- endif -%}
277
+ {%- if section.settings.trust_3 != blank -%}<span>&#9989; {{ section.settings.trust_3 }}</span>{%- endif -%}
278
+ </div>
279
+ {%- endif -%}
280
+ </div>
281
+ </div>
282
+ </div>
283
+ </section>
284
+
285
+ <script src="{{ 'runwell-bundle-system.js' | asset_url }}" defer="defer"></script>
286
+ {%- endif -%}
287
+
288
+ {% schema %}
289
+ {
290
+ "name": "Bundle quantity builder",
291
+ "tag": "section",
292
+ "class": "section-runwell-bundle-quantity-builder",
293
+ "settings": [
294
+ { "type": "header", "content": "Source product" },
295
+ { "type": "product", "id": "product", "label": "Product", "info": "The bundle reads runwell.bundle_quantity_tiers + sale_prefix + rating + free gift handle from this product's metafields." },
296
+
297
+ { "type": "header", "content": "Sale prefix fallback" },
298
+ { "type": "text", "id": "sale_prefix", "label": "Sale prefix fallback", "info": "Used only when the product has no runwell.bundle_sale_prefix metafield set." },
299
+
300
+ { "type": "header", "content": "Rating display" },
301
+ { "type": "checkbox", "id": "show_rating", "label": "Show star rating", "default": true },
302
+
303
+ { "type": "header", "content": "Trust badges" },
304
+ { "type": "checkbox", "id": "show_trust_badges", "label": "Show trust badges", "default": true },
305
+ { "type": "text", "id": "trust_1", "label": "Badge 1", "default": "90-Day Guarantee" },
306
+ { "type": "text", "id": "trust_2", "label": "Badge 2", "default": "Easy Returns" },
307
+ { "type": "text", "id": "trust_3", "label": "Badge 3", "default": "Free Shipping" },
308
+
309
+ { "type": "header", "content": "Layout" },
310
+ { "type": "color_scheme", "id": "color_scheme", "label": "Color scheme", "default": "scheme-1" },
311
+ { "type": "range", "id": "padding_top", "label": "Top padding", "min": 0, "max": 160, "step": 4, "default": 40, "unit": "px" },
312
+ { "type": "range", "id": "padding_bottom", "label": "Bottom padding", "min": 0, "max": 160, "step": 4, "default": 40, "unit": "px" }
313
+ ],
314
+ "presets": [
315
+ { "name": "Bundle: quantity builder", "category": "Runwell" }
316
+ ]
317
+ }
318
+ {% endschema %}
@@ -13,8 +13,21 @@
13
13
 
14
14
  {%- if bundle_product != blank -%}
15
15
  {%- assign card_variant = variant | default: 'default' -%}
16
- {%- assign product = bundle_product -%}
17
- {% render 'runwell-bundle-data', product: bundle_product %}
16
+
17
+ {%- comment -%}
18
+ Inline metafield reads. Liquid `render` is scope-isolated, so calling
19
+ `render 'runwell-bundle-data'` does not leak its assigns back here.
20
+ We read the values directly off the bundle product to keep the card
21
+ self-contained.
22
+ {%- endcomment -%}
23
+ {%- assign card_mf = bundle_product.metafields.runwell -%}
24
+ {%- assign bundle_savings_pct = card_mf.bundle_savings_pct.value | default: 0 -%}
25
+ {%- assign bundle_components = card_mf.bundle_components.value -%}
26
+ {%- assign bundle_cross_supplier = card_mf.bundle_cross_supplier.value | default: false -%}
27
+ {%- assign bundle_supplier_count = card_mf.bundle_supplier_count.value | default: 1 -%}
28
+ {%- assign bundle_price = bundle_product.price -%}
29
+ {%- assign bundle_subtotal = bundle_product.compare_at_price | default: bundle_product.price -%}
30
+
18
31
  {%- assign component_count = 0 -%}
19
32
  {%- if bundle_components and bundle_components.size > 0 -%}
20
33
  {%- assign component_count = bundle_components.size -%}
@@ -0,0 +1,85 @@
1
+ {%- comment -%}
2
+ runwell-bundle-system: runwell-bundle-cart-xsell.liquid (snippet).
3
+
4
+ Surface 4 (bundle xsell), placed INLINE inside a tenant's cart-drawer
5
+ snippet. Companion to sections/runwell-bundle-cart-xsell.liquid for
6
+ tenants whose cart drawer lives outside the section-group renderer
7
+ (e.g. Dawn-shape themes that {% render 'cart-drawer' %} from layout).
8
+
9
+ Drops a self-refreshing slot. The JS hook in assets/runwell-bundle-system.js
10
+ refetches this slot on /cart/{add,change,update,clear} responses.
11
+
12
+ Expected usage inside cart-drawer.liquid:
13
+
14
+ {%- if cart.item_count > 0 -%}
15
+ {% render 'runwell-bundle-cart-xsell',
16
+ fallback_snippet: 'runwell-cart-xsell',
17
+ eyebrow: 'Complete the stack' %}
18
+ {%- endif -%}
19
+
20
+ Inputs (all optional):
21
+ fallback_snippet name of a snippet to render when no bundle match
22
+ eyebrow label above the bundle card (default "Complete the stack")
23
+ cta_label CTA copy passed through to the card (display-only)
24
+ {%- endcomment -%}
25
+
26
+ {%- assign xsell_eyebrow = eyebrow | default: 'Complete the stack' -%}
27
+
28
+ {%- comment -%} Build cart_handles via capture+split: cart.items | map: 'handle' returns empty strings on Dawn-shape themes. {%- endcomment -%}
29
+ {%- capture xsell_cart_handles_csv -%}
30
+ {%- for item in cart.items -%}{{ item.product.handle }}|{%- endfor -%}
31
+ {%- endcapture -%}
32
+ {%- assign xsell_cart_handles = xsell_cart_handles_csv | strip | split: '|' -%}
33
+
34
+ {%- assign xsell_index_instance = shop.metaobjects.bundle_index.values | first -%}
35
+ {%- assign xsell_index = xsell_index_instance.entries.value -%}
36
+
37
+ {%- assign xsell_handle = '' -%}
38
+ {%- assign xsell_score = 0 -%}
39
+
40
+ {%- if xsell_index and xsell_index.bundles -%}
41
+ {%- for entry in xsell_index.bundles -%}
42
+ {%- assign candidate_handle = entry[0] -%}
43
+ {%- assign candidate_components = entry[1].components -%}
44
+ {%- if xsell_cart_handles contains candidate_handle -%}{%- continue -%}{%- endif -%}
45
+
46
+ {%- assign overlap = 0 -%}
47
+ {%- assign component_total = 0 -%}
48
+ {%- for c in candidate_components -%}
49
+ {%- assign component_total = component_total | plus: 1 -%}
50
+ {%- if xsell_cart_handles contains c[0] -%}
51
+ {%- assign overlap = overlap | plus: 1 -%}
52
+ {%- endif -%}
53
+ {%- endfor -%}
54
+
55
+ {%- if overlap > 0 and overlap < component_total and overlap > xsell_score -%}
56
+ {%- assign xsell_score = overlap -%}
57
+ {%- assign xsell_handle = candidate_handle -%}
58
+ {%- endif -%}
59
+ {%- endfor -%}
60
+ {%- endif -%}
61
+
62
+ <div id="CartDrawer-XsellSlot" data-runwell-xsell-slot>
63
+ {%- if xsell_handle != '' -%}
64
+ {%- assign xsell_product = all_products[xsell_handle] -%}
65
+ {%- if xsell_product != blank and xsell_product.handle != blank -%}
66
+ <div class="runwell-bundle-cart-xsell" data-runwell-bundle-xsell>
67
+ <p class="runwell-bundle-cart-xsell__eyebrow">{{ xsell_eyebrow }}</p>
68
+ {% render 'runwell-bundle-card', bundle_product: xsell_product, variant: 'compact' %}
69
+ <p class="runwell-bundle-cart-xsell__hint">
70
+ You already have {{ xsell_score }} of these. Save by getting the full stack.
71
+ </p>
72
+ </div>
73
+ {%- endif -%}
74
+ {%- elsif fallback_snippet != blank -%}
75
+ {%- comment -%}
76
+ No bundle match: tenants opt into a fallback snippet (e.g. simple
77
+ cart-cross-sell). The snippet renders into the same slot so the
78
+ refresh hook can swap it out later when a bundle match arrives.
79
+ {%- endcomment -%}
80
+ {% render fallback_snippet %}
81
+ {%- endif -%}
82
+ </div>
83
+
84
+ {{ 'runwell-bundle-system.css' | asset_url | stylesheet_tag }}
85
+ <script src="{{ 'runwell-bundle-system.js' | asset_url }}" defer="defer"></script>
@@ -0,0 +1,88 @@
1
+ # scratch-popup
2
+
3
+ Mystery-discount scratch popup. Email capture + foil-card scratch-to-reveal that unlocks a discount code per visitor.
4
+
5
+ **Status:** ready (v0.1.0). Liquid section + CSS + JS implementation complete. See `SPEC.md` for the design doc.
6
+
7
+ ## Install on a tenant store
8
+
9
+ ### 1. Create the 3 discount codes on the store
10
+
11
+ Use the toolkit's helper script (requires the now-live `write_discounts` scope on Runwell Ops):
12
+
13
+ ```
14
+ bash infrastructure/scripts/shopify/shopify-discount-create.sh <store> MYSTERY10 10 \
15
+ --title "Mystery discount 10%" --once-per-customer
16
+
17
+ bash infrastructure/scripts/shopify/shopify-discount-create.sh <store> MYSTERY15 15 \
18
+ --title "Mystery discount 15%" --once-per-customer
19
+
20
+ bash infrastructure/scripts/shopify/shopify-discount-create.sh <store> MYSTERY20 20 \
21
+ --title "Mystery discount 20%" --once-per-customer
22
+ ```
23
+
24
+ Tenant can use any codes / percentages; just make sure the section settings match.
25
+
26
+ ### 2. Drop the section into the theme
27
+
28
+ Add the `Runwell scratch popup` section to a layout that loads on every page (typically the theme.liquid or the index template). Section presets are pre-filled with the MYSTERY10/15/20 defaults.
29
+
30
+ ### 3. Configure (section settings)
31
+
32
+ | Setting group | Notes |
33
+ |---|---|
34
+ | Copy | Eyebrow, heading, subheading, button labels |
35
+ | Tier 1 / 2 / 3 | Code (must match step 1), percentage, probability (must sum to 100) |
36
+ | Triggers | First-visit delay (default 8s), exit intent toggle, scratch threshold (default 60%) |
37
+ | Design | Foil color, accent color |
38
+
39
+ ### 4. Verify
40
+
41
+ - Open the storefront in an incognito window.
42
+ - After the configured delay, the modal appears.
43
+ - Enter an email + click Continue. Email lands in Shopify Customers list with tags `newsletter, scratch-popup`.
44
+ - Scratch the foil. After ~60% erased, the discount auto-reveals.
45
+ - Copy button copies the code. Shop now applies the code at checkout.
46
+
47
+ ## Suppression
48
+
49
+ Session flag stored at `localStorage.runwell_scratch_seen` for 30 days after reveal or dismiss. Visitor sees the popup once per 30-day window per device.
50
+
51
+ ## What it replaces
52
+
53
+ | Replaces | Notes |
54
+ |---|---|
55
+ | Privy / Justuno "Spin to win" | KH (Patrick Franco) and Claspo benchmark both say spin-the-wheel is gimmicky and underperforms. |
56
+ | Fixed "10% off" / WELCOME15 popups | Mystery mechanic creates curiosity; perceived value is higher. |
57
+ | Generic "enter email for updates" forms | Forms with no incentive are dead weight. |
58
+
59
+ ## Why mystery-scratch, not spin-the-wheel
60
+
61
+ Cited in `_knowledge-hub/marketing/2026-03-07-rating-email-capture-popups-for-ecommerce-stores.md`:
62
+
63
+ - Mystery discount: **Great** (creates curiosity, interactive for cold traffic)
64
+ - Spin the wheel: **Avoid** (feels gimmicky now)
65
+ - Free gift, bundle discount: Great
66
+ - Plain "enter email for updates": Avoid
67
+
68
+ Plus Claspo benchmark (779M popup impressions): scratch card 11.29% conversion vs. 3.53% baseline (3.2x lift).
69
+
70
+ ## Scopes required on the app installing this module
71
+
72
+ | Scope | Used for |
73
+ |---|---|
74
+ | `write_discounts` | Generate one-time discount codes per visitor email |
75
+ | `read_discounts` | Look up + dedupe existing codes |
76
+ | `write_customers` | Bind discount code to the captured email |
77
+ | `read_customers` | Email dedup |
78
+
79
+ All four are live on Runwell Ops (`runwell-ops-3`).
80
+
81
+ ## First implementation target
82
+
83
+ Lushi v2 storefront. See `claude-PM/_clients/capital-v/lushi/tickets/mystery-scratch-popup.md`.
84
+
85
+ ## Related modules
86
+
87
+ - `exit-intent` (sibling display-layer module; pattern to lift from)
88
+ - `cart-cross-sell` (also a conversion-category module)