@runwell/shopify-toolkit 0.21.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.
Files changed (29) hide show
  1. package/lib/init.js +13 -2
  2. package/modules/INDEX.md +3 -3
  3. package/modules/runwell-bundle-system/admin-metafields.json +15 -3
  4. package/modules/runwell-bundle-system/assets/runwell-bundle-quantity-builder.css +383 -0
  5. package/modules/runwell-bundle-system/assets/runwell-bundle-system.css +246 -0
  6. package/modules/runwell-bundle-system/assets/runwell-bundle-system.js +359 -0
  7. package/modules/runwell-bundle-system/module.json +18 -4
  8. package/modules/runwell-bundle-system/qa/v1.5-mobile-qa-checklist.md +103 -0
  9. package/modules/runwell-bundle-system/sections/runwell-bundle-cart-xsell.liquid +2 -1
  10. package/modules/runwell-bundle-system/sections/runwell-bundle-collection.liquid +20 -8
  11. package/modules/runwell-bundle-system/sections/runwell-bundle-pdp-banner.liquid +2 -1
  12. package/modules/runwell-bundle-system/sections/runwell-bundle-pdp-pairs-with.liquid +2 -1
  13. package/modules/runwell-bundle-system/sections/runwell-bundle-pdp.liquid +15 -1
  14. package/modules/runwell-bundle-system/sections/runwell-bundle-quantity-builder.liquid +318 -0
  15. package/modules/runwell-bundle-system/snippets/runwell-bundle-byob-picker-accordion.liquid +84 -0
  16. package/modules/runwell-bundle-system/snippets/runwell-bundle-byob-picker-grid.liquid +72 -0
  17. package/modules/runwell-bundle-system/snippets/runwell-bundle-byob-picker-radio.liquid +77 -0
  18. package/modules/runwell-bundle-system/snippets/runwell-bundle-byob-picker.liquid +71 -0
  19. package/modules/runwell-bundle-system/snippets/runwell-bundle-byob-summary.liquid +39 -0
  20. package/modules/runwell-bundle-system/snippets/runwell-bundle-card.liquid +15 -2
  21. package/modules/runwell-bundle-system/snippets/runwell-bundle-cart-xsell.liquid +85 -0
  22. package/modules/runwell-bundle-system/snippets/runwell-bundle-data.liquid +8 -0
  23. package/modules/scratch-popup/README.md +88 -0
  24. package/modules/scratch-popup/SPEC.md +120 -0
  25. package/modules/scratch-popup/assets/runwell-scratch-popup.css +315 -0
  26. package/modules/scratch-popup/assets/runwell-scratch-popup.js +367 -0
  27. package/modules/scratch-popup/module.json +128 -0
  28. package/modules/scratch-popup/sections/runwell-scratch-popup.liquid +184 -0
  29. package/package.json +1 -1
@@ -0,0 +1,103 @@
1
+ # v1.5 mobile QA checklist: Mode C BYOB + admin block
2
+
3
+ Re-validates the storefront at 375 px after Mode C BYOB lands, plus admin block usability on Shopify's mobile admin app. Mirrors BS-14's v1 pass; adds the new v1.5 surfaces.
4
+
5
+ ## Environment
6
+
7
+ - Real iPhone (iPhone 12 or newer; iOS 17+).
8
+ - Safari for storefront; Shopify mobile admin app for admin block.
9
+ - Staging tenant with at least:
10
+ - 1 Mode A bundle (Lusha pattern, for regression).
11
+ - 1 Mode B bundle (Lushi pattern, for regression).
12
+ - 1 Mode C bundle per layout: grid, accordion, radio.
13
+
14
+ ## Storefront checks (per Mode C layout)
15
+
16
+ ### Grid layout
17
+
18
+ - [ ] Picker renders at 2 columns at 375 px.
19
+ - [ ] Each candidate card has a 44 px tap target.
20
+ - [ ] Tap toggles selection; checkmark / border state matches the rest of the bundle module.
21
+ - [ ] Live counter updates as you tap.
22
+ - [ ] Subtotal + savings refresh client-side.
23
+ - [ ] ATC stays disabled until min picks reached; enables when in range.
24
+ - [ ] Max-pick cap drops the oldest selection (or refuses the new one, depending on convention).
25
+ - [ ] Required candidates are pre-checked and not deselectable.
26
+ - [ ] Unavailable candidates render disabled, not removed.
27
+ - [ ] Submit posts a multi-line cart add; cart drawer reflects the picks.
28
+
29
+ ### Accordion layout
30
+
31
+ - [ ] Categories render as `<details>` blocks; first one open by default.
32
+ - [ ] Per-category count badge updates on each pick.
33
+ - [ ] Total picks across categories enforced against bundle_byob_max_picks.
34
+ - [ ] Required candidates ship pre-checked in their parent category.
35
+
36
+ ### Radio layout
37
+
38
+ - [ ] Each category renders as a radio group; first option pre-selected.
39
+ - [ ] One pick per category enforced (radios).
40
+ - [ ] Total picks equals category count; ATC enables once every category has a pick.
41
+ - [ ] Switching pick within a category updates subtotal + total.
42
+
43
+ ## Storefront regression (v1 surfaces with a Mode C bundle in catalog)
44
+
45
+ - [ ] /bundles page lists the Mode C bundle alongside Mode A and Mode B.
46
+ - [ ] Bundle card hero variant renders correctly with the BYOB savings badge.
47
+ - [ ] PDP pairs-with widget shows the Mode C bundle when a component is the current PDP.
48
+ - [ ] Home page curated stacks strip includes the Mode C bundle when picked.
49
+ - [ ] Cart drawer cross-sell offers the Mode C bundle when a partial set is in cart.
50
+ - [ ] PDP banner with a Mode C bundle reads "Save N% when bundled".
51
+
52
+ ## Admin block checks (Shopify mobile admin app)
53
+
54
+ Best-effort: mobile admin is rare; document any limitations.
55
+
56
+ ### Composition panel (BS-205)
57
+
58
+ - [ ] Mode selector toggles between tier / component / candidate sub-UIs.
59
+ - [ ] Add / remove / reorder works in each mode.
60
+ - [ ] Validation banner surfaces when constraints are violated.
61
+
62
+ ### Pricing panel (BS-206)
63
+
64
+ - [ ] Pricing model dropdown limits to allowed modes per bundle_mode.
65
+ - [ ] Value editor changes shape with model.
66
+ - [ ] Save disabled when value is empty.
67
+
68
+ ### Surfaces panel (BS-207)
69
+
70
+ - [ ] All 6 surface toggles render.
71
+ - [ ] Toggle state persists after save.
72
+
73
+ ### Cross-supplier banner (BS-208)
74
+
75
+ - [ ] Banner appears when components span multiple suppliers.
76
+ - [ ] Mentions the actual supplier count.
77
+ - [ ] Suppresses when count is 1.
78
+
79
+ ### Bundle preview (BS-209)
80
+
81
+ - [ ] Iframe renders at 375 px.
82
+ - [ ] Preview reflects the saved state (not buffered edits).
83
+
84
+ ## Cross-cutting
85
+
86
+ - [ ] No horizontal scroll on any storefront surface.
87
+ - [ ] All tap targets >= 44 px.
88
+ - [ ] Total module bytes still under 80 KB (CSS + JS, pre-minification).
89
+ - [ ] No console errors on storefront or admin.
90
+ - [ ] Lighthouse mobile score >= 80 on the BYOB PDP.
91
+
92
+ ## Screenshot capture
93
+
94
+ Per surface, capture 3 states (default, mid-interaction, validation error).
95
+
96
+ Path: `runwell-shopify-toolkit/docs/bundle-system/qa/v1.5-screenshots-YYYY-MM-DD/`.
97
+
98
+ ## Sign-off
99
+
100
+ - [ ] All applicable checks passed.
101
+ - [ ] Screenshots captured and committed.
102
+ - [ ] P1 failures fixed; P2 logged for v1.5 patch.
103
+ - [ ] QA owner + date: ___________________
@@ -20,7 +20,8 @@
20
20
  {%- if settings.bundle_system__surface_4_cart_drawer_xsell_enabled == false -%}
21
21
  {%- comment -%} Tenant has disabled this surface. {%- endcomment -%}
22
22
  {%- else -%}
23
- {%- assign bundle_index = shop.metaobjects.bundle_index.entries.value -%}
23
+ {%- assign bundle_index_instance = shop.metaobjects.bundle_index.values | first -%}
24
+ {%- assign bundle_index = bundle_index_instance.entries.value -%}
24
25
  {%- assign render_slot = false -%}
25
26
  {%- if bundle_index and bundle_index.products and bundle_index.bundles -%}
26
27
  {%- assign render_slot = true -%}
@@ -11,15 +11,27 @@
11
11
 
12
12
  {%- assign mode = section.settings.mode | default: 'grid' -%}
13
13
 
14
- {%- comment -%} Build the bundles list from collection.products if present, otherwise scan all_products. {%- endcomment -%}
15
- {%- assign source_products = collection.products -%}
16
- {%- if source_products == blank or source_products.size == 0 -%}
17
- {%- assign source_products = all_products -%}
14
+ {%- comment -%}
15
+ Build the bundles list. Prefer the bundle_index shop metaobject when
16
+ populated (one read; deterministic). Fall back to scanning all_products
17
+ for products tagged 'bundle'.
18
+ {%- endcomment -%}
19
+ {%- assign bundle_index_instance = shop.metaobjects.bundle_index.values | first -%}
20
+ {%- assign bundle_index = bundle_index_instance.entries.value -%}
21
+ {%- assign bundles = '' | split: '' -%}
22
+ {%- if bundle_index and bundle_index.bundles -%}
23
+ {%- for entry in bundle_index.bundles -%}
24
+ {%- assign bp = all_products[entry[0]] -%}
25
+ {%- if bp != blank and bp.handle != blank -%}
26
+ {%- assign bundles = bundles | concat: bp -%}
27
+ {%- endif -%}
28
+ {%- endfor -%}
18
29
  {%- endif -%}
19
-
20
- {%- assign bundles_meta = source_products | where: 'metafields.runwell.bundle_mode' -%}
21
- {%- assign bundles = bundles_meta -%}
22
- {%- if bundles == empty or bundles.size == 0 -%}
30
+ {%- if bundles.size == 0 -%}
31
+ {%- assign source_products = collection.products -%}
32
+ {%- if source_products == blank or source_products.size == 0 -%}
33
+ {%- assign source_products = all_products -%}
34
+ {%- endif -%}
23
35
  {%- assign bundles = source_products | where: 'tags', 'bundle' -%}
24
36
  {%- endif -%}
25
37
 
@@ -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] -%}
@@ -58,6 +58,18 @@
58
58
  bundle_savings_amount: bundle_savings_amount,
59
59
  bundle_savings_pct: bundle_savings_pct
60
60
  %}
61
+ {%- elsif bundle_mode == 'byob' -%}
62
+ {% render 'runwell-bundle-byob-picker',
63
+ product: product,
64
+ bundle_pricing_model: bundle_pricing_model,
65
+ bundle_pricing_value: bundle_pricing_value,
66
+ bundle_byob_candidates: bundle_byob_candidates,
67
+ bundle_byob_min_picks: bundle_byob_min_picks,
68
+ bundle_byob_max_picks: bundle_byob_max_picks,
69
+ bundle_byob_layout: bundle_byob_layout,
70
+ bundle_byob_categories: bundle_byob_categories,
71
+ bundle_byob_required_handles: bundle_byob_required_handles
72
+ %}
61
73
  {%- endif -%}
62
74
 
63
75
  {%- if bundle_fomo_mode != 'none' and bundle_fomo_mode != blank -%}
@@ -100,6 +112,8 @@
100
112
  "presets": [
101
113
  { "name": "Bundle PDP", "category": "Runwell" }
102
114
  ],
103
- "templates": ["product.bundle"]
115
+ "enabled_on": {
116
+ "templates": ["product"]
117
+ }
104
118
  }
105
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 %}
@@ -0,0 +1,84 @@
1
+ {%- comment -%}
2
+ runwell-bundle-system: runwell-bundle-byob-picker-accordion.liquid.
3
+ Mode C, accordion layout. Candidates grouped by bundle_byob_categories.
4
+ One category open at a time. Total picks across all categories
5
+ enforced via JS.
6
+
7
+ Inputs:
8
+ bundle_byob_candidates, bundle_byob_min_picks, bundle_byob_max_picks,
9
+ bundle_byob_categories ([{label, handles}]),
10
+ bundle_byob_required_handles, bundle_pricing_model, bundle_pricing_value.
11
+ {%- endcomment -%}
12
+
13
+ {%- if bundle_byob_categories and bundle_byob_categories.size > 0 -%}
14
+ {%- assign required_handles = '' -%}
15
+ {%- if bundle_byob_required_handles -%}
16
+ {%- capture required_handles -%}
17
+ {%- for r in bundle_byob_required_handles -%}{{ r.handle }},{%- endfor -%}
18
+ {%- endcapture -%}
19
+ {%- endif -%}
20
+
21
+ <div
22
+ class="runwell-bundle-system__byob-picker runwell-bundle-system__byob-picker--accordion"
23
+ data-runwell-byob-picker
24
+ data-byob-layout="accordion"
25
+ data-byob-min="{{ bundle_byob_min_picks }}"
26
+ data-byob-max="{{ bundle_byob_max_picks }}"
27
+ data-byob-pricing-model="{{ bundle_pricing_model }}"
28
+ data-byob-pricing-value="{{ bundle_pricing_value | json | escape }}"
29
+ >
30
+ {%- for category in bundle_byob_categories -%}
31
+ <details class="runwell-bundle-system__byob-accordion" {% if forloop.first %}open{% endif %}>
32
+ <summary class="runwell-bundle-system__byob-accordion-summary">
33
+ <span class="runwell-bundle-system__byob-accordion-label">{{ category.label }}</span>
34
+ <span class="runwell-bundle-system__byob-accordion-count" data-runwell-byob-cat-count>0</span>
35
+ </summary>
36
+ <div class="runwell-bundle-system__byob-accordion-body">
37
+ {%- for handle in category.handles -%}
38
+ {%- assign candidate = all_products[handle] -%}
39
+ {%- if candidate != blank -%}
40
+ {%- assign is_required = false -%}
41
+ {%- if required_handles contains handle -%}{%- assign is_required = true -%}{%- endif -%}
42
+ <label
43
+ class="runwell-bundle-system__byob-candidate{% if is_required %} runwell-bundle-system__byob-candidate--required{% endif %}"
44
+ data-handle="{{ candidate.handle }}"
45
+ data-price="{{ candidate.price }}"
46
+ data-variant-id="{{ candidate.selected_or_first_available_variant.id }}"
47
+ data-category="{{ category.label | escape }}"
48
+ >
49
+ <input
50
+ type="checkbox"
51
+ class="runwell-bundle-system__byob-input"
52
+ name="byob-pick"
53
+ value="{{ candidate.handle }}"
54
+ {% if is_required %}checked disabled{% endif %}
55
+ {% unless candidate.available %}disabled{% endunless %}
56
+ >
57
+ <span class="runwell-bundle-system__byob-candidate-media">
58
+ {%- if candidate.featured_image -%}
59
+ {{ candidate.featured_image | image_url: width: 240 | image_tag:
60
+ width: 120,
61
+ height: 120,
62
+ loading: 'lazy',
63
+ class: 'runwell-bundle-system__byob-candidate-thumb',
64
+ alt: candidate.title
65
+ }}
66
+ {%- endif -%}
67
+ </span>
68
+ <span class="runwell-bundle-system__byob-candidate-body">
69
+ <span class="runwell-bundle-system__byob-candidate-title">{{ candidate.title }}</span>
70
+ <span class="runwell-bundle-system__byob-candidate-price">{{ candidate.price | money }}</span>
71
+ {%- if is_required -%}
72
+ <span class="runwell-bundle-system__byob-candidate-badge">Always included</span>
73
+ {%- endif -%}
74
+ </span>
75
+ </label>
76
+ {%- endif -%}
77
+ {%- endfor -%}
78
+ </div>
79
+ </details>
80
+ {%- endfor -%}
81
+ </div>
82
+ {%- else -%}
83
+ <p class="runwell-bundle-system__empty">Accordion layout requires bundle_byob_categories. Falling back to grid layout via runwell-bundle-byob-picker.liquid.</p>
84
+ {%- endif -%}