@runwell/shopify-toolkit 0.23.0 → 0.24.1

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.
@@ -9,7 +9,7 @@
9
9
  { "key": "bundle_pricing_model", "name": "Bundle pricing model", "type": "single_line_text_field", "validations": [{ "name": "choices", "value": "[\"tier_quantity\",\"fixed_price\",\"fixed_bundle_price\",\"percent_off_subtotal\",\"dollar_off_subtotal\"]" }] },
10
10
  { "key": "bundle_pricing_value", "name": "Bundle pricing value (JSON)", "type": "json", "description": "Shape varies by pricing_model. See spec.md section 2.2." },
11
11
  { "key": "bundle_components", "name": "Bundle components (JSON)", "type": "json", "description": "Required for multi_product / mix_match. Array of {product_handle, qty}." },
12
- { "key": "bundle_quantity_tiers", "name": "Bundle quantity tiers (JSON)", "type": "json", "description": "Required for quantity_tiers. Array of {qty, discount_pct}. Mirrors quantity_breaks schema." },
12
+ { "key": "bundle_quantity_tiers", "name": "Bundle quantity tiers (JSON)", "type": "json", "description": "Required for quantity_tiers. Array of {qty, discount_pct, label?, popular?, free_shipping?, free_gift?}. label is the visible row label (e.g. '2x Sculpting Brush'); falls back to '{qty}x {product.title}' when absent. popular adds the MOST POPULAR badge. free_shipping shows the FREE SHIPPING tag on that tier. free_gift means buying this tier appends the bundle_free_gift_handle product as a $0 line item via ATC." },
13
13
  { "key": "bundle_show_in_catalog", "name": "Show in main catalog", "type": "boolean", "default": true },
14
14
  { "key": "bundle_surfaces_enabled", "name": "Surfaces enabled (JSON)", "type": "json", "description": "Per-bundle surface allowlist. Array of 1..6. Absent = all enabled at tenant level." },
15
15
  { "key": "bundle_copy", "name": "Per-surface copy (JSON)", "type": "json", "description": "Per-surface eyebrow/heading/cta overrides." },
@@ -21,6 +21,9 @@
21
21
  { "key": "bundle_cross_supplier", "name": "Cross-supplier", "type": "boolean", "default": false },
22
22
  { "key": "bundle_supplier_count", "name": "Supplier count", "type": "number_integer" },
23
23
  { "key": "bundle_savings_pct", "name": "Computed savings percent", "type": "number_decimal", "description": "Precomputed for fast banner / card render. Recomputed by runwell-shopify rebuild-bundle-index." },
24
+ { "key": "bundle_sale_prefix", "name": "Sale prefix (Mode A)", "type": "single_line_text_field", "description": "Subheading copy displayed under the product title on Mode A quantity-tier surfaces. Example: 'Spring Sculpting Sale: Ends June 5'." },
25
+ { "key": "bundle_rating_score", "name": "Rating score display", "type": "single_line_text_field", "description": "Rating string shown above the title (e.g. '4.8/5'). Display-only; not wired to a review provider for v1." },
26
+ { "key": "bundle_rating_count", "name": "Rating count display", "type": "single_line_text_field", "description": "Review count string shown next to the rating score (e.g. '2,400+ Reviews')." },
24
27
  { "key": "bundle_byob_candidates", "name": "BYOB candidates", "type": "list.product_reference", "description": "Mode C only. Pool of candidate products the customer can pick from.", "v1_5": true },
25
28
  { "key": "bundle_byob_min_picks", "name": "BYOB min picks", "type": "number_integer", "description": "Mode C only. Minimum required selections before ATC enables.", "v1_5": true },
26
29
  { "key": "bundle_byob_max_picks", "name": "BYOB max picks", "type": "number_integer", "description": "Mode C only. Maximum allowed selections.", "v1_5": true },
@@ -0,0 +1,383 @@
1
+ .runwell-bundle-quantity-builder__grid {
2
+ display: grid;
3
+ grid-template-columns: 1fr 1fr;
4
+ gap: 2.4rem;
5
+ align-items: start;
6
+ }
7
+
8
+ /* Slideshow Gallery */
9
+ .runwell-bundle-quantity-builder__slideshow {
10
+ position: relative;
11
+ border-radius: 12px;
12
+ overflow: hidden;
13
+ background: #E8D5C4;
14
+ }
15
+
16
+ .runwell-bundle-quantity-builder__slide {
17
+ display: none;
18
+ }
19
+
20
+ .runwell-bundle-quantity-builder__slide--active {
21
+ display: block;
22
+ }
23
+
24
+ .runwell-bundle-quantity-builder__img {
25
+ width: 100%;
26
+ height: auto;
27
+ display: block;
28
+ aspect-ratio: 1 / 1;
29
+ object-fit: cover;
30
+ max-height: calc(100vh - 260px);
31
+ }
32
+
33
+ /* Thumbnail Strip - ALWAYS visible including mobile */
34
+ .runwell-bundle-quantity-builder__thumbnails {
35
+ display: flex;
36
+ gap: 8px;
37
+ margin-top: 12px;
38
+ overflow-x: auto;
39
+ scrollbar-width: thin;
40
+ scrollbar-color: #EADFD4 transparent;
41
+ padding-bottom: 4px;
42
+ -webkit-overflow-scrolling: touch;
43
+ }
44
+
45
+ .runwell-bundle-quantity-builder__thumbnails::-webkit-scrollbar {
46
+ height: 4px;
47
+ }
48
+
49
+ .runwell-bundle-quantity-builder__thumbnails::-webkit-scrollbar-thumb {
50
+ background: #EADFD4;
51
+ border-radius: 2px;
52
+ }
53
+
54
+ .runwell-bundle-quantity-builder__thumb {
55
+ flex: 0 0 72px;
56
+ height: 72px;
57
+ border-radius: 8px;
58
+ overflow: hidden;
59
+ border: 2px solid transparent;
60
+ cursor: pointer;
61
+ padding: 0;
62
+ background: #E8D5C4;
63
+ transition: border-color 0.2s, opacity 0.2s;
64
+ opacity: 0.7;
65
+ }
66
+
67
+ .runwell-bundle-quantity-builder__thumb--active {
68
+ border-color: #3F5B4C;
69
+ opacity: 1;
70
+ }
71
+
72
+ .runwell-bundle-quantity-builder__thumb:hover {
73
+ opacity: 1;
74
+ }
75
+
76
+ .runwell-bundle-quantity-builder__thumb img {
77
+ width: 100%;
78
+ height: 100%;
79
+ object-fit: cover;
80
+ }
81
+
82
+ /* Rating */
83
+ .runwell-bundle-quantity-builder__rating {
84
+ display: flex;
85
+ align-items: center;
86
+ gap: 8px;
87
+ margin-bottom: 0;
88
+ }
89
+
90
+ .runwell-bundle-quantity-builder__stars {
91
+ color: #E8B931;
92
+ font-size: calc(var(--font-body-scale) * 1.6rem);
93
+ letter-spacing: 1px;
94
+ }
95
+
96
+ .runwell-bundle-quantity-builder__rating-text {
97
+ font-size: calc(var(--font-body-scale) * 1.4rem);
98
+ font-weight: 600;
99
+ font-style: italic;
100
+ }
101
+
102
+ /* Title */
103
+ .runwell-bundle-quantity-builder__title {
104
+ margin-top: 0.4rem;
105
+ margin-bottom: 1rem;
106
+ }
107
+
108
+ /* Sale heading */
109
+ .runwell-bundle-quantity-builder__sale-heading {
110
+ display: flex;
111
+ align-items: center;
112
+ gap: 1.2rem;
113
+ margin-bottom: 1.2rem;
114
+ }
115
+
116
+ .runwell-bundle-quantity-builder__sale-line {
117
+ flex: 1;
118
+ height: 1px;
119
+ background: #2A2622;
120
+ }
121
+
122
+ .runwell-bundle-quantity-builder__sale-text {
123
+ font-weight: 700;
124
+ font-size: calc(var(--font-body-scale) * 1.5rem);
125
+ white-space: nowrap;
126
+ }
127
+
128
+ /* Scarcity indicator */
129
+ .runwell-bundle-quantity-builder__scarcity {
130
+ display: flex;
131
+ align-items: center;
132
+ justify-content: center;
133
+ gap: 6px;
134
+ font-size: calc(var(--font-body-scale) * 1.3rem);
135
+ font-weight: 500;
136
+ color: #b45309;
137
+ margin-bottom: 1rem;
138
+ }
139
+
140
+ .runwell-bundle-quantity-builder__scarcity-dot {
141
+ width: 8px;
142
+ height: 8px;
143
+ border-radius: 50%;
144
+ background: #b45309;
145
+ animation: scarcity-pulse 2s ease-in-out infinite;
146
+ }
147
+
148
+ @keyframes scarcity-pulse {
149
+ 0%, 100% { opacity: 1; }
150
+ 50% { opacity: 0.4; }
151
+ }
152
+
153
+ /* Options */
154
+ .runwell-bundle-quantity-builder__options {
155
+ display: flex;
156
+ flex-direction: column;
157
+ gap: 10px;
158
+ margin-bottom: 1.2rem;
159
+ }
160
+
161
+ .runwell-bundle-quantity-builder__option {
162
+ display: flex;
163
+ align-items: flex-start;
164
+ gap: 12px;
165
+ padding: 14px 18px;
166
+ border: 2px solid #EADFD4;
167
+ border-radius: 10px;
168
+ cursor: pointer;
169
+ transition: border-color 0.2s, box-shadow 0.2s;
170
+ position: relative;
171
+ }
172
+
173
+ .runwell-bundle-quantity-builder__option:hover {
174
+ border-color: #3F5B4C;
175
+ }
176
+
177
+ .runwell-bundle-quantity-builder__option--selected {
178
+ border-color: #2A2622;
179
+ box-shadow: 0 0 0 1px #2A2622;
180
+ }
181
+
182
+ .runwell-bundle-quantity-builder__option--popular {
183
+ border-color: #3F5B4C;
184
+ position: relative;
185
+ }
186
+
187
+ .runwell-bundle-quantity-builder__popular-badge {
188
+ position: absolute;
189
+ top: -10px;
190
+ right: 16px;
191
+ background: #3F5B4C;
192
+ color: #fff;
193
+ font-size: var(--runwell-body-size);
194
+ font-weight: 700;
195
+ padding: 2px 10px;
196
+ border-radius: 4px;
197
+ letter-spacing: 0.06em;
198
+ text-transform: uppercase;
199
+ }
200
+
201
+ .runwell-bundle-quantity-builder__option input {
202
+ position: absolute;
203
+ opacity: 0;
204
+ pointer-events: none;
205
+ }
206
+
207
+ .runwell-bundle-quantity-builder__option-radio {
208
+ width: 22px;
209
+ height: 22px;
210
+ border-radius: 50%;
211
+ border: 2px solid #EADFD4;
212
+ flex-shrink: 0;
213
+ margin-top: 2px;
214
+ display: flex;
215
+ align-items: center;
216
+ justify-content: center;
217
+ transition: border-color 0.2s;
218
+ }
219
+
220
+ .runwell-bundle-quantity-builder__option--selected .runwell-bundle-quantity-builder__option-radio {
221
+ border-color: #2A2622;
222
+ }
223
+
224
+ .runwell-bundle-quantity-builder__option--selected .runwell-bundle-quantity-builder__option-radio::after {
225
+ content: '';
226
+ width: 12px;
227
+ height: 12px;
228
+ border-radius: 50%;
229
+ background: #2A2622;
230
+ }
231
+
232
+ .runwell-bundle-quantity-builder__option-content {
233
+ flex: 1;
234
+ }
235
+
236
+ .runwell-bundle-quantity-builder__option-header {
237
+ display: flex;
238
+ justify-content: space-between;
239
+ align-items: baseline;
240
+ gap: 12px;
241
+ }
242
+
243
+ .runwell-bundle-quantity-builder__option-title {
244
+ font-weight: 700;
245
+ font-size: calc(var(--font-heading-scale) * 1.5rem);
246
+ }
247
+
248
+ .runwell-bundle-quantity-builder__option-pricing {
249
+ display: flex;
250
+ align-items: baseline;
251
+ gap: 8px;
252
+ flex-shrink: 0;
253
+ }
254
+
255
+ .runwell-bundle-quantity-builder__option-price {
256
+ font-weight: 700;
257
+ font-size: calc(var(--font-body-scale) * 1.8rem);
258
+ }
259
+
260
+ .runwell-bundle-quantity-builder__option-compare {
261
+ font-size: calc(var(--font-body-scale) * 1.3rem);
262
+ text-decoration: line-through;
263
+ opacity: 0.5;
264
+ }
265
+
266
+ .runwell-bundle-quantity-builder__option-badges {
267
+ display: flex;
268
+ gap: 6px;
269
+ margin-top: 6px;
270
+ flex-wrap: wrap;
271
+ }
272
+
273
+ .runwell-bundle-quantity-builder__badge {
274
+ font-size: calc(var(--font-body-scale) * 1.1rem);
275
+ font-weight: 700;
276
+ padding: 2px 8px;
277
+ border-radius: 4px;
278
+ text-transform: uppercase;
279
+ letter-spacing: 0.03em;
280
+ }
281
+
282
+ .runwell-bundle-quantity-builder__badge--save {
283
+ background: #3F5B4C;
284
+ color: #fff;
285
+ }
286
+
287
+ .runwell-bundle-quantity-builder__badge--shipping {
288
+ color: #3F5B4C;
289
+ font-weight: 600;
290
+ }
291
+
292
+ .runwell-bundle-quantity-builder__free-gift {
293
+ display: flex;
294
+ align-items: center;
295
+ gap: 6px;
296
+ margin-top: 8px;
297
+ padding: 8px 12px;
298
+ background: rgba(63, 91, 76, 0.06);
299
+ border-radius: 6px;
300
+ font-size: calc(var(--font-body-scale) * 1.3rem);
301
+ font-weight: 600;
302
+ }
303
+
304
+ .runwell-bundle-quantity-builder__gift-icon {
305
+ font-size: calc(var(--font-body-scale) * 1.6rem);
306
+ }
307
+
308
+ /* ATC Button */
309
+ .runwell-bundle-quantity-builder__atc {
310
+ width: 100%;
311
+ padding: 1.6rem;
312
+ font-size: calc(var(--font-body-scale) * 1.6rem);
313
+ font-weight: 700;
314
+ letter-spacing: 0.06em;
315
+ text-transform: uppercase;
316
+ background: #2A2622;
317
+ color: #fff;
318
+ border: none;
319
+ border-radius: 8px;
320
+ cursor: pointer;
321
+ transition: background 0.2s;
322
+ margin-bottom: 1.2rem;
323
+ }
324
+
325
+ .runwell-bundle-quantity-builder__atc:hover {
326
+ background: #3F5B4C;
327
+ }
328
+
329
+ /* Trust badges */
330
+ .runwell-bundle-quantity-builder__trust {
331
+ display: flex;
332
+ justify-content: center;
333
+ gap: 2rem;
334
+ font-size: calc(var(--font-body-scale) * 1.3rem);
335
+ opacity: 0.7;
336
+ flex-wrap: wrap;
337
+ }
338
+
339
+ /* Sticky ATC on mobile */
340
+ @media screen and (max-width: 989px) {
341
+ .runwell-bundle-quantity-builder__grid {
342
+ grid-template-columns: 1fr;
343
+ gap: 2rem;
344
+ }
345
+
346
+ .runwell-bundle-quantity-builder__atc-wrap {
347
+ position: sticky;
348
+ bottom: 0;
349
+ z-index: 10;
350
+ background: #fff;
351
+ padding: 12px 0 max(12px, env(safe-area-inset-bottom));
352
+ box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.08);
353
+ margin: 0 -1.5rem;
354
+ padding-left: 1.5rem;
355
+ padding-right: 1.5rem;
356
+ }
357
+ }
358
+
359
+ @media screen and (max-width: 749px) {
360
+ .runwell-bundle-quantity-builder__option-header {
361
+ flex-direction: column;
362
+ gap: 4px;
363
+ }
364
+
365
+ .runwell-bundle-quantity-builder__trust {
366
+ flex-direction: column;
367
+ align-items: center;
368
+ gap: 0.6rem;
369
+ }
370
+ }
371
+
372
+ /* Runwell additions: placeholder states when gallery is empty */
373
+ .runwell-bundle-quantity-builder__img--placeholder,
374
+ .runwell-bundle-quantity-builder__thumb-img--placeholder {
375
+ width: 100%;
376
+ height: 100%;
377
+ min-height: 320px;
378
+ background: linear-gradient(135deg, var(--runwell-oat, #F5F0EE), var(--runwell-cream, #EDE6D8));
379
+ display: block;
380
+ }
381
+ .runwell-bundle-quantity-builder__thumb-img--placeholder {
382
+ min-height: 0;
383
+ }
@@ -72,6 +72,204 @@
72
72
  });
73
73
  }
74
74
 
75
+ /* -------------------------------------------------------------------
76
+ * Mode A quantity builder
77
+ *
78
+ * Per-tier radio selection state + image thumbnail swap + ATC.
79
+ * ATC handles two cases:
80
+ * 1. Plain tier: standard product form POST.
81
+ * 2. free_gift tier: intercept the submit, build a multi-line
82
+ * /cart/add.js POST with the bundle qty + 1 of the free gift
83
+ * product (read from data-free-gift-variant-id on the section).
84
+ * After success: open Dawn cart drawer + dispatch cart:updated so any
85
+ * downstream xsell listeners refresh.
86
+ * ----------------------------------------------------------------- */
87
+ function initQuantityBuilder() {
88
+ document
89
+ .querySelectorAll('[data-runwell-bundle-quantity-builder]')
90
+ .forEach(function (section) {
91
+ // Tier selection visual state
92
+ section
93
+ .querySelectorAll('[data-runwell-tier-radio]')
94
+ .forEach(function (radio) {
95
+ radio.addEventListener('change', function () {
96
+ section
97
+ .querySelectorAll('.runwell-bundle-quantity-builder__option')
98
+ .forEach(function (label) {
99
+ const input = label.querySelector('[data-runwell-tier-radio]');
100
+ label.classList.toggle(
101
+ 'runwell-bundle-quantity-builder__option--selected',
102
+ input ? input.checked : false
103
+ );
104
+ });
105
+ });
106
+ });
107
+
108
+ // Thumbnail swap (image gallery)
109
+ section.querySelectorAll('[data-thumb]').forEach(function (btn) {
110
+ btn.addEventListener('click', function () {
111
+ const idx = btn.dataset.thumb;
112
+ section
113
+ .querySelectorAll('.runwell-bundle-quantity-builder__slide')
114
+ .forEach(function (s) {
115
+ s.classList.toggle(
116
+ 'runwell-bundle-quantity-builder__slide--active',
117
+ s.dataset.slide === idx
118
+ );
119
+ });
120
+ section
121
+ .querySelectorAll('.runwell-bundle-quantity-builder__thumb')
122
+ .forEach(function (t) {
123
+ t.classList.toggle(
124
+ 'runwell-bundle-quantity-builder__thumb--active',
125
+ t === btn
126
+ );
127
+ });
128
+ });
129
+ });
130
+
131
+ // ATC handler
132
+ const form = section.querySelector('[data-runwell-bundle-form]');
133
+ if (!form) return;
134
+ const atc = form.querySelector('[data-runwell-bundle-atc]');
135
+ const giftVariantId = section.dataset.freeGiftVariantId || '';
136
+
137
+ form.addEventListener('submit', function (e) {
138
+ e.preventDefault();
139
+ const selected = form.querySelector(
140
+ '.runwell-bundle-quantity-builder__option--selected'
141
+ );
142
+ if (!selected) return;
143
+ const qty = parseInt(selected.dataset.tierQty || '1', 10);
144
+ const wantsGift = selected.dataset.tierFreeGift === 'true' && giftVariantId !== '';
145
+ const variantId = form.querySelector('input[name="id"]').value;
146
+
147
+ setAtcState(atc, 'loading');
148
+
149
+ const items = [{ id: parseInt(variantId, 10), quantity: qty }];
150
+ if (wantsGift) {
151
+ items.push({ id: parseInt(giftVariantId, 10), quantity: 1 });
152
+ }
153
+
154
+ fetch('/cart/add.js', {
155
+ method: 'POST',
156
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
157
+ body: JSON.stringify({ items: items }),
158
+ })
159
+ .then(function (r) {
160
+ if (!r.ok) throw new Error('add failed');
161
+ return r.json();
162
+ })
163
+ .then(function () {
164
+ setAtcState(atc, 'added');
165
+ document.dispatchEvent(new CustomEvent('cart:updated', { bubbles: true }));
166
+ // Open Dawn cart drawer if present
167
+ const drawer = document.querySelector('cart-drawer');
168
+ if (drawer && typeof drawer.open === 'function') drawer.open();
169
+ setTimeout(function () {
170
+ setAtcState(atc, 'idle');
171
+ }, 2000);
172
+ })
173
+ .catch(function () {
174
+ setAtcState(atc, 'idle');
175
+ });
176
+ });
177
+ });
178
+ }
179
+
180
+ function setAtcState(atc, state) {
181
+ if (!atc) return;
182
+ const textEl = atc.querySelector('.runwell-bundle-quantity-builder__atc-text');
183
+ const loadEl = atc.querySelector('.runwell-bundle-quantity-builder__atc-loading');
184
+ if (!textEl || !loadEl) return;
185
+ if (state === 'loading') {
186
+ textEl.style.display = 'none';
187
+ loadEl.style.display = 'inline';
188
+ atc.disabled = true;
189
+ } else if (state === 'added') {
190
+ loadEl.style.display = 'none';
191
+ textEl.textContent = 'ADDED';
192
+ textEl.style.display = 'inline';
193
+ } else {
194
+ loadEl.style.display = 'none';
195
+ textEl.textContent = 'ADD TO CART';
196
+ textEl.style.display = 'inline';
197
+ atc.disabled = false;
198
+ }
199
+ }
200
+
201
+ /* -------------------------------------------------------------------
202
+ * Cart-drawer xsell refresh
203
+ *
204
+ * The cart drawer is server-rendered once. When customers add or
205
+ * change items via async fetch (PDP ATC, qty controls, remove), the
206
+ * bundle xsell area would otherwise stay stale. We hook fetch() to
207
+ * detect cart mutations, then re-render #CartDrawer-XsellSlot from
208
+ * the current page's cart-drawer section via Section Rendering API.
209
+ *
210
+ * Tenants opt in by adding <div id="CartDrawer-XsellSlot"> around the
211
+ * bundle xsell in their cart-drawer snippet (or by using the
212
+ * snippets/runwell-bundle-cart-xsell.liquid wrapper which does this).
213
+ * If the slot isn't present, this is a no-op.
214
+ * ----------------------------------------------------------------- */
215
+ function initCartMutationRefresh() {
216
+ if (window.__runwellBundleCartRefreshInstalled) return;
217
+ window.__runwellBundleCartRefreshInstalled = true;
218
+
219
+ const MUTATION_RE = /\/cart\/(add|change|update|clear)(?:\.js)?(?:[?#]|$)/;
220
+ let pending = null;
221
+
222
+ function scheduleRefresh() {
223
+ if (pending) return;
224
+ pending = setTimeout(function () {
225
+ pending = null;
226
+ refresh();
227
+ }, 120);
228
+ }
229
+
230
+ function refresh() {
231
+ const slot = document.getElementById('CartDrawer-XsellSlot');
232
+ if (!slot) return;
233
+ const url =
234
+ window.location.pathname +
235
+ (window.location.search ? window.location.search + '&' : '?') +
236
+ 'sections=cart-drawer';
237
+ fetch(url, { credentials: 'same-origin' })
238
+ .then(function (r) {
239
+ return r.json();
240
+ })
241
+ .then(function (data) {
242
+ const html = data && data['cart-drawer'];
243
+ if (!html) return;
244
+ const doc = new DOMParser().parseFromString(html, 'text/html');
245
+ const fresh = doc.getElementById('CartDrawer-XsellSlot');
246
+ if (!fresh) return;
247
+ slot.innerHTML = fresh.innerHTML;
248
+ })
249
+ .catch(function () {
250
+ /* silent; keep current xsell */
251
+ });
252
+ }
253
+
254
+ const origFetch = window.fetch;
255
+ window.fetch = function () {
256
+ const args = arguments;
257
+ const p = origFetch.apply(this, args);
258
+ try {
259
+ const first = args[0];
260
+ const url = typeof first === 'string' ? first : (first && first.url) || '';
261
+ if (MUTATION_RE.test(url)) {
262
+ p.then(scheduleRefresh, function () {});
263
+ }
264
+ } catch (e) {}
265
+ return p;
266
+ };
267
+
268
+ ['cart:updated', 'cart:refresh', 'cart-drawer:updated'].forEach(function (name) {
269
+ document.addEventListener(name, scheduleRefresh);
270
+ });
271
+ }
272
+
75
273
  function initFilterChip() {
76
274
  document
77
275
  .querySelectorAll('[data-runwell-bundle-filter-chip]')
@@ -433,6 +631,8 @@
433
631
  initFilterChip();
434
632
  initCartXsell();
435
633
  initByob();
634
+ initQuantityBuilder();
635
+ initCartMutationRefresh();
436
636
  });
437
637
  } else {
438
638
  initFomo();
@@ -440,5 +640,7 @@
440
640
  initFilterChip();
441
641
  initCartXsell();
442
642
  initByob();
643
+ initQuantityBuilder();
644
+ initCartMutationRefresh();
443
645
  }
444
646
  })();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runwell-bundle-system",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "category": "catalog",
5
5
  "source": "runwell",
6
6
  "base": "bundle-system",
@@ -13,10 +13,12 @@
13
13
  "sections/runwell-bundle-home-stacks.liquid",
14
14
  "sections/runwell-bundle-cart-xsell.liquid",
15
15
  "sections/runwell-bundle-pdp-banner.liquid",
16
- "sections/runwell-bundle-collection.liquid"
16
+ "sections/runwell-bundle-collection.liquid",
17
+ "sections/runwell-bundle-quantity-builder.liquid"
17
18
  ],
18
19
  "snippets": [
19
20
  "snippets/runwell-bundle-card.liquid",
21
+ "snippets/runwell-bundle-cart-xsell.liquid",
20
22
  "snippets/runwell-bundle-quantity-tiers.liquid",
21
23
  "snippets/runwell-bundle-multi-product.liquid",
22
24
  "snippets/runwell-bundle-pricing.liquid",
@@ -32,7 +34,8 @@
32
34
  ],
33
35
  "assets": [
34
36
  "assets/runwell-bundle-system.css",
35
- "assets/runwell-bundle-system.js"
37
+ "assets/runwell-bundle-system.js",
38
+ "assets/runwell-bundle-quantity-builder.css"
36
39
  ]
37
40
  },
38
41
  "config": {
@@ -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