@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
@@ -607,6 +607,252 @@
607
607
  opacity: 0.7;
608
608
  }
609
609
 
610
+ /* ---------- Mode C BYOB ---------- */
611
+
612
+ .runwell-bundle-system__byob-summary {
613
+ position: sticky;
614
+ top: 0;
615
+ z-index: 5;
616
+ display: flex;
617
+ flex-wrap: wrap;
618
+ align-items: center;
619
+ gap: 12px;
620
+ padding: 12px 14px;
621
+ border-radius: var(--runwell-radius-md, 12px);
622
+ background: var(--runwell-surface-card, #fff);
623
+ box-shadow: var(--runwell-shadow-card, 0 2px 8px rgba(0,0,0,0.04));
624
+ margin-bottom: 16px;
625
+ }
626
+
627
+ .runwell-bundle-system__byob-counter {
628
+ margin: 0;
629
+ font-size: 0.95rem;
630
+ font-weight: 600;
631
+ display: flex;
632
+ align-items: baseline;
633
+ gap: 4px;
634
+ }
635
+
636
+ .runwell-bundle-system__byob-counter-of,
637
+ .runwell-bundle-system__byob-counter-label {
638
+ font-weight: 400;
639
+ opacity: 0.75;
640
+ font-size: 0.85rem;
641
+ }
642
+
643
+ .runwell-bundle-system__byob-pricing {
644
+ margin-left: auto;
645
+ display: flex;
646
+ align-items: baseline;
647
+ gap: 8px;
648
+ flex-wrap: wrap;
649
+ }
650
+
651
+ .runwell-bundle-system__byob-pricing-subtotal {
652
+ opacity: 0.55;
653
+ font-size: 0.85rem;
654
+ }
655
+
656
+ .runwell-bundle-system__byob-pricing-current {
657
+ font-size: 1.2rem;
658
+ font-weight: 700;
659
+ }
660
+
661
+ .runwell-bundle-system__byob-pricing-badge {
662
+ padding: 4px 10px;
663
+ font-size: 0.78rem;
664
+ font-weight: 600;
665
+ border-radius: 999px;
666
+ background: var(--runwell-accent, currentColor);
667
+ color: var(--runwell-cream, #fff);
668
+ }
669
+
670
+ .runwell-bundle-system__byob-helper {
671
+ width: 100%;
672
+ margin: 0;
673
+ font-size: 0.8rem;
674
+ opacity: 0.75;
675
+ }
676
+
677
+ .runwell-bundle-system__byob-picker--grid {
678
+ display: grid;
679
+ grid-template-columns: repeat(2, 1fr);
680
+ gap: 10px;
681
+ }
682
+
683
+ @media (min-width: 640px) {
684
+ .runwell-bundle-system__byob-picker--grid {
685
+ grid-template-columns: repeat(3, 1fr);
686
+ gap: 12px;
687
+ }
688
+ }
689
+
690
+ @media (min-width: 1024px) {
691
+ .runwell-bundle-system__byob-picker--grid {
692
+ grid-template-columns: repeat(4, 1fr);
693
+ }
694
+ }
695
+
696
+ .runwell-bundle-system__byob-candidate {
697
+ position: relative;
698
+ display: flex;
699
+ flex-direction: column;
700
+ gap: 6px;
701
+ min-height: 44px;
702
+ padding: 10px;
703
+ border: 2px solid color-mix(in srgb, currentColor 10%, transparent);
704
+ border-radius: var(--runwell-radius-md, 12px);
705
+ background: var(--runwell-surface-card, #fff);
706
+ cursor: pointer;
707
+ transition: border-color 120ms ease, background 120ms ease;
708
+ }
709
+
710
+ .runwell-bundle-system__byob-candidate:hover {
711
+ border-color: color-mix(in srgb, var(--runwell-accent, currentColor) 50%, transparent);
712
+ }
713
+
714
+ .runwell-bundle-system__byob-candidate:has(.runwell-bundle-system__byob-input:checked) {
715
+ border-color: var(--runwell-accent, currentColor);
716
+ background: color-mix(in srgb, var(--runwell-accent, currentColor) 6%, var(--runwell-surface-card, #fff));
717
+ }
718
+
719
+ .runwell-bundle-system__byob-candidate--required {
720
+ border-style: dashed;
721
+ opacity: 0.95;
722
+ }
723
+
724
+ .runwell-bundle-system__byob-candidate--unavailable {
725
+ opacity: 0.55;
726
+ cursor: not-allowed;
727
+ }
728
+
729
+ .runwell-bundle-system__byob-input {
730
+ position: absolute;
731
+ opacity: 0;
732
+ pointer-events: none;
733
+ }
734
+
735
+ .runwell-bundle-system__byob-candidate-thumb {
736
+ width: 100%;
737
+ height: auto;
738
+ aspect-ratio: 1 / 1;
739
+ object-fit: cover;
740
+ border-radius: var(--runwell-radius-sm, 4px);
741
+ display: block;
742
+ }
743
+
744
+ .runwell-bundle-system__byob-candidate-body {
745
+ display: flex;
746
+ flex-direction: column;
747
+ gap: 2px;
748
+ }
749
+
750
+ .runwell-bundle-system__byob-candidate-title {
751
+ font-size: 0.9rem;
752
+ font-weight: 600;
753
+ line-height: 1.3;
754
+ }
755
+
756
+ .runwell-bundle-system__byob-candidate-price {
757
+ font-size: 0.85rem;
758
+ opacity: 0.8;
759
+ }
760
+
761
+ .runwell-bundle-system__byob-candidate-badge {
762
+ margin-top: 4px;
763
+ align-self: flex-start;
764
+ padding: 2px 8px;
765
+ font-size: 0.7rem;
766
+ font-weight: 600;
767
+ border-radius: 999px;
768
+ background: color-mix(in srgb, var(--runwell-blue, currentColor) 18%, transparent);
769
+ }
770
+
771
+ .runwell-bundle-system__byob-picker--accordion {
772
+ display: flex;
773
+ flex-direction: column;
774
+ gap: 8px;
775
+ }
776
+
777
+ .runwell-bundle-system__byob-accordion {
778
+ border: 1px solid color-mix(in srgb, currentColor 10%, transparent);
779
+ border-radius: var(--runwell-radius-md, 12px);
780
+ background: var(--runwell-surface-card, #fff);
781
+ }
782
+
783
+ .runwell-bundle-system__byob-accordion-summary {
784
+ display: flex;
785
+ align-items: center;
786
+ gap: 8px;
787
+ padding: 14px 16px;
788
+ min-height: 44px;
789
+ font-weight: 600;
790
+ cursor: pointer;
791
+ list-style: none;
792
+ }
793
+
794
+ .runwell-bundle-system__byob-accordion-summary::-webkit-details-marker {
795
+ display: none;
796
+ }
797
+
798
+ .runwell-bundle-system__byob-accordion-count {
799
+ margin-left: auto;
800
+ padding: 2px 10px;
801
+ font-size: 0.78rem;
802
+ font-weight: 600;
803
+ border-radius: 999px;
804
+ background: color-mix(in srgb, var(--runwell-accent, currentColor) 16%, transparent);
805
+ }
806
+
807
+ .runwell-bundle-system__byob-accordion-body {
808
+ display: grid;
809
+ grid-template-columns: repeat(2, 1fr);
810
+ gap: 10px;
811
+ padding: 0 16px 16px 16px;
812
+ }
813
+
814
+ @media (min-width: 768px) {
815
+ .runwell-bundle-system__byob-accordion-body {
816
+ grid-template-columns: repeat(3, 1fr);
817
+ }
818
+ }
819
+
820
+ .runwell-bundle-system__byob-picker--radio {
821
+ display: flex;
822
+ flex-direction: column;
823
+ gap: 16px;
824
+ }
825
+
826
+ .runwell-bundle-system__byob-radio-group {
827
+ border: 0;
828
+ margin: 0;
829
+ padding: 0;
830
+ display: flex;
831
+ flex-direction: column;
832
+ gap: 8px;
833
+ }
834
+
835
+ .runwell-bundle-system__byob-radio-legend {
836
+ font-size: 0.85rem;
837
+ font-weight: 600;
838
+ letter-spacing: 0.04em;
839
+ text-transform: uppercase;
840
+ opacity: 0.75;
841
+ margin-bottom: 4px;
842
+ }
843
+
844
+ .runwell-bundle-system__byob-candidate--radio {
845
+ flex-direction: row;
846
+ align-items: center;
847
+ padding: 10px 12px;
848
+ }
849
+
850
+ .runwell-bundle-system__byob-candidate--radio .runwell-bundle-system__byob-candidate-thumb {
851
+ width: 56px;
852
+ height: 56px;
853
+ aspect-ratio: 1 / 1;
854
+ }
855
+
610
856
  /* ---------- Surface 4: cart drawer bundle cross-sell ---------- */
611
857
 
612
858
  .runwell-bundle-cart-xsell {
@@ -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]')
@@ -271,17 +469,178 @@
271
469
  });
272
470
  }
273
471
 
472
+ /* ---------- Mode C BYOB ---------- */
473
+
474
+ function parsePricingValue(raw) {
475
+ if (raw == null || raw === '') return null;
476
+ try { return JSON.parse(raw); } catch (e) { return null; }
477
+ }
478
+
479
+ function computeByobPrice(subtotalCents, pricingModel, pricingValue) {
480
+ if (pricingModel === 'fixed_bundle_price' || pricingModel === 'fixed_price') {
481
+ const v = typeof pricingValue === 'object' && pricingValue !== null
482
+ ? (pricingValue.amount || pricingValue.price || 0)
483
+ : pricingValue;
484
+ return Math.max(0, parseInt(v, 10) || 0);
485
+ }
486
+ if (pricingModel === 'percent_off_subtotal') {
487
+ const pct = typeof pricingValue === 'object' && pricingValue !== null
488
+ ? (pricingValue.pct || pricingValue.percent || 0)
489
+ : pricingValue;
490
+ const discounted = subtotalCents * (1 - (parseFloat(pct) || 0) / 100);
491
+ return Math.max(0, Math.round(discounted));
492
+ }
493
+ if (pricingModel === 'dollar_off_subtotal') {
494
+ const off = typeof pricingValue === 'object' && pricingValue !== null
495
+ ? (pricingValue.amount || pricingValue.off || 0)
496
+ : pricingValue;
497
+ return Math.max(0, subtotalCents - (parseInt(off, 10) || 0));
498
+ }
499
+ return subtotalCents;
500
+ }
501
+
502
+ function formatByobMoney(cents) {
503
+ return '$' + (cents / 100).toFixed(2);
504
+ }
505
+
506
+ function initByob() {
507
+ document
508
+ .querySelectorAll('[data-runwell-byob-picker]')
509
+ .forEach(function (picker) {
510
+ const form = picker.closest('form');
511
+ if (!form) return;
512
+ const root = form.parentElement || form;
513
+ const summary = root.querySelector('[data-runwell-byob-summary]');
514
+ const atc = form.querySelector('[data-runwell-byob-atc]');
515
+
516
+ const layout = picker.dataset.byobLayout;
517
+ const min = parseInt(picker.dataset.byobMin, 10) || 0;
518
+ const max = parseInt(picker.dataset.byobMax, 10) || 0;
519
+ const pricingModel = picker.dataset.byobPricingModel;
520
+ const pricingValue = parsePricingValue(picker.dataset.byobPricingValue);
521
+
522
+ function getSelected() {
523
+ return Array.from(picker.querySelectorAll('.runwell-bundle-system__byob-input:checked'))
524
+ .map(function (input) {
525
+ const label = input.closest('.runwell-bundle-system__byob-candidate');
526
+ return {
527
+ handle: label.dataset.handle,
528
+ price: parseInt(label.dataset.price, 10) || 0,
529
+ variantId: label.dataset.variantId,
530
+ category: label.dataset.category || null,
531
+ input: input
532
+ };
533
+ });
534
+ }
535
+
536
+ function updateAccordionCounts() {
537
+ if (layout !== 'accordion') return;
538
+ picker.querySelectorAll('.runwell-bundle-system__byob-accordion').forEach(function (acc) {
539
+ const count = acc.querySelectorAll('.runwell-bundle-system__byob-input:checked').length;
540
+ const badge = acc.querySelector('[data-runwell-byob-cat-count]');
541
+ if (badge) badge.textContent = count;
542
+ });
543
+ }
544
+
545
+ function enforceMax(selected) {
546
+ if (layout === 'radio') return;
547
+ if (selected.length <= max) return;
548
+ const overflow = selected.slice(max);
549
+ overflow.forEach(function (s) {
550
+ if (!s.input.disabled) s.input.checked = false;
551
+ });
552
+ }
553
+
554
+ function render() {
555
+ let selected = getSelected();
556
+ enforceMax(selected);
557
+ selected = getSelected();
558
+
559
+ const count = selected.length;
560
+ const subtotal = selected.reduce(function (acc, s) { return acc + s.price; }, 0);
561
+ const total = computeByobPrice(subtotal, pricingModel, pricingValue);
562
+ const savings = Math.max(0, subtotal - total);
563
+
564
+ if (summary) {
565
+ const selEl = summary.querySelector('[data-runwell-byob-selected]');
566
+ const totalEl = summary.querySelector('[data-runwell-byob-total]');
567
+ const subEl = summary.querySelector('[data-runwell-byob-subtotal]');
568
+ const subAmt = summary.querySelector('[data-runwell-byob-subtotal-amount]');
569
+ const savEl = summary.querySelector('[data-runwell-byob-savings]');
570
+ const savAmt = summary.querySelector('[data-runwell-byob-savings-amount]');
571
+ const helper = summary.querySelector('[data-runwell-byob-helper]');
572
+
573
+ if (selEl) selEl.textContent = count;
574
+ if (totalEl) totalEl.textContent = formatByobMoney(total);
575
+ const isFixed = pricingModel === 'fixed_bundle_price' || pricingModel === 'fixed_price';
576
+ if (savings > 0 && !isFixed) {
577
+ if (subEl) { subEl.hidden = false; if (subAmt) subAmt.textContent = formatByobMoney(subtotal); }
578
+ if (savEl) { savEl.hidden = false; if (savAmt) savAmt.textContent = formatByobMoney(savings); }
579
+ } else {
580
+ if (subEl) subEl.hidden = true;
581
+ if (savEl) savEl.hidden = true;
582
+ }
583
+ if (helper) helper.style.display = count >= min ? 'none' : '';
584
+ }
585
+
586
+ if (atc) {
587
+ const ok = count >= min && count <= max;
588
+ atc.disabled = !ok;
589
+ }
590
+ updateAccordionCounts();
591
+ }
592
+
593
+ picker.addEventListener('change', function (e) {
594
+ if (!e.target.matches('.runwell-bundle-system__byob-input')) return;
595
+ render();
596
+ });
597
+
598
+ form.addEventListener('submit', function (e) {
599
+ const selected = getSelected();
600
+ if (selected.length < min || selected.length > max) {
601
+ e.preventDefault();
602
+ return;
603
+ }
604
+ e.preventDefault();
605
+ const items = selected.map(function (s) {
606
+ return { id: parseInt(s.variantId, 10), quantity: 1 };
607
+ });
608
+ fetch('/cart/add.js', {
609
+ method: 'POST',
610
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
611
+ body: JSON.stringify({ items: items })
612
+ })
613
+ .then(function (r) { return r.json(); })
614
+ .then(function () {
615
+ document.dispatchEvent(new CustomEvent('cart:refresh'));
616
+ window.location.href = '/cart';
617
+ })
618
+ .catch(function () {
619
+ if (atc) atc.disabled = false;
620
+ });
621
+ });
622
+
623
+ render();
624
+ });
625
+ }
626
+
274
627
  if (document.readyState === 'loading') {
275
628
  document.addEventListener('DOMContentLoaded', function () {
276
629
  initFomo();
277
630
  initTierSync();
278
631
  initFilterChip();
279
632
  initCartXsell();
633
+ initByob();
634
+ initQuantityBuilder();
635
+ initCartMutationRefresh();
280
636
  });
281
637
  } else {
282
638
  initFomo();
283
639
  initTierSync();
284
640
  initFilterChip();
285
641
  initCartXsell();
642
+ initByob();
643
+ initQuantityBuilder();
644
+ initCartMutationRefresh();
286
645
  }
287
646
  })();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runwell-bundle-system",
3
- "version": "0.1.0",
3
+ "version": "0.4.0",
4
4
  "category": "catalog",
5
5
  "source": "runwell",
6
6
  "base": "bundle-system",
@@ -13,21 +13,29 @@
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",
23
25
  "snippets/runwell-bundle-cross-supplier.liquid",
24
26
  "snippets/runwell-bundle-fomo.liquid",
25
27
  "snippets/runwell-bundle-free-gift.liquid",
26
- "snippets/runwell-bundle-data.liquid"
28
+ "snippets/runwell-bundle-data.liquid",
29
+ "snippets/runwell-bundle-byob-picker.liquid",
30
+ "snippets/runwell-bundle-byob-picker-grid.liquid",
31
+ "snippets/runwell-bundle-byob-picker-accordion.liquid",
32
+ "snippets/runwell-bundle-byob-picker-radio.liquid",
33
+ "snippets/runwell-bundle-byob-summary.liquid"
27
34
  ],
28
35
  "assets": [
29
36
  "assets/runwell-bundle-system.css",
30
- "assets/runwell-bundle-system.js"
37
+ "assets/runwell-bundle-system.js",
38
+ "assets/runwell-bundle-quantity-builder.css"
31
39
  ]
32
40
  },
33
41
  "config": {
@@ -74,6 +82,12 @@
74
82
  "url": "https://admin.shopify.com/store/{store_handle}/products/new",
75
83
  "summary": "Use the Shopify Bundles app to create the bundle (Apps > Bundles > Create). The app creates a bundle product. SKU convention: BUNDLE-<handle>. Then open the bundle product, scroll to Metafields, and fill the runwell.bundle_* fields per the bundle's mode."
76
84
  },
85
+ {
86
+ "id": "configure-byob-mode",
87
+ "label": "Configure a BYOB (Mode C) bundle product",
88
+ "url": "https://admin.shopify.com/store/{store_handle}/products",
89
+ "summary": "Mode C only. Set bundle_mode = 'byob'. Required: bundle_byob_candidates (list of product references), bundle_byob_min_picks, bundle_byob_max_picks, bundle_pricing_model (fixed_bundle_price / percent_off_subtotal / dollar_off_subtotal), bundle_pricing_value. Optional: bundle_byob_layout (grid default, accordion, radio), bundle_byob_categories (JSON), bundle_byob_required_handles. Install a Shopify Functions discount that mirrors the pricing model so the savings apply at checkout."
90
+ },
77
91
  {
78
92
  "id": "configure-quantity-tier-discount-function",
79
93
  "label": "Create a Shopify Functions discount for quantity-tier mode (Mode A only)",