@runwell/shopify-toolkit 0.21.0 → 0.23.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.
- package/lib/init.js +13 -2
- package/modules/INDEX.md +3 -3
- package/modules/runwell-bundle-system/admin-metafields.json +11 -2
- package/modules/runwell-bundle-system/assets/runwell-bundle-system.css +246 -0
- package/modules/runwell-bundle-system/assets/runwell-bundle-system.js +157 -0
- package/modules/runwell-bundle-system/module.json +13 -2
- package/modules/runwell-bundle-system/qa/v1.5-mobile-qa-checklist.md +103 -0
- package/modules/runwell-bundle-system/sections/runwell-bundle-pdp.liquid +12 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-byob-picker-accordion.liquid +84 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-byob-picker-grid.liquid +72 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-byob-picker-radio.liquid +77 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-byob-picker.liquid +71 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-byob-summary.liquid +39 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-data.liquid +8 -0
- package/package.json +1 -1
package/lib/init.js
CHANGED
|
@@ -65,7 +65,7 @@ export async function init(flags) {
|
|
|
65
65
|
store: 'TODO.myshopify.com',
|
|
66
66
|
theme_path: '.',
|
|
67
67
|
baseline,
|
|
68
|
-
toolkit_version: '^0.
|
|
68
|
+
toolkit_version: '^0.21.0',
|
|
69
69
|
brand: {
|
|
70
70
|
name: client.charAt(0).toUpperCase() + client.slice(1),
|
|
71
71
|
primary: '#1A1A1A',
|
|
@@ -76,7 +76,18 @@ export async function init(flags) {
|
|
|
76
76
|
tagline: ''
|
|
77
77
|
},
|
|
78
78
|
modules: {
|
|
79
|
-
'_shared/css-tokens': { enabled: true, config: {} }
|
|
79
|
+
'_shared/css-tokens': { enabled: true, config: {} },
|
|
80
|
+
'runwell-bundle-system': {
|
|
81
|
+
enabled: false,
|
|
82
|
+
config: {
|
|
83
|
+
surface_1_collection_page_enabled: false,
|
|
84
|
+
surface_2_pdp_pairs_with_enabled: false,
|
|
85
|
+
surface_3_home_stacks_enabled: false,
|
|
86
|
+
surface_4_cart_drawer_xsell_enabled: false,
|
|
87
|
+
surface_5_pdp_banner_enabled: false,
|
|
88
|
+
surface_6_collection_filter_enabled: false
|
|
89
|
+
}
|
|
90
|
+
}
|
|
80
91
|
}
|
|
81
92
|
};
|
|
82
93
|
|
package/modules/INDEX.md
CHANGED
|
@@ -47,7 +47,7 @@ Total modules: 39.
|
|
|
47
47
|
| `recently-viewed` | pdp | (native build) | sections:1 assets:2 | eyebrow, heading, background_color | (none) | (none) |
|
|
48
48
|
| `reviews` | social-proof | (native build) | sections:1 assets:1 | heading | (none) | (none) |
|
|
49
49
|
| `risk-reversal` | conversion | (native build) | sections:1 | icon, heading, body, link_label, link_url, background_color, text_color | (none) | (none) |
|
|
50
|
-
| `runwell-bundle-system` | catalog | the legacy bundle-builder module | sections:6 snippets:
|
|
50
|
+
| `runwell-bundle-system` | catalog | the legacy bundle-builder module | sections:6 snippets:13 assets:2 | surface_1_collection_page_enabled, surface_2_pdp_pairs_with_enabled, surface_3_home_stacks_enabled, surface_4_cart_drawer_xsell_enabled, surface_5_pdp_banner_enabled, surface_6_collection_filter_enabled, surface_2_eyebrow, surface_2_heading, surface_3_eyebrow, surface_3_heading, surface_4_eyebrow, surface_4_cta, surface_5_copy_template, cross_supplier_disclosure, home_strip_position | (none) | install-shopify-bundles + define-bundle-metafields + create-bundle-index-metaobject + create-first-bundle-product + configure-byob-mode + configure-quantity-tier-discount-function + configure-free-gift-discount + create-bundles-collection-page-template + add-home-stacks-section + add-pdp-pairs-with-section + add-pdp-banner-section + verify-cart-drawer-coordination + rebuild-bundle-index |
|
|
51
51
|
| `scrolling-ticker` | social-proof | announcement-bar / scrolling-text apps | sections:1 assets:1 | (none) | (none) | (none) |
|
|
52
52
|
| `shipping-bar` | conversion | (native build) | sections:1 | threshold_cents, message_below, message_qualified, message_default, background_color, text_color | (none) | (none) |
|
|
53
53
|
| `social-proof-banner` | social-proof | fixed-text social-proof apps | sections:1 assets:1 | (none) | (none) | (none) |
|
|
@@ -269,9 +269,9 @@ Total modules: 39.
|
|
|
269
269
|
- Category: catalog
|
|
270
270
|
- Replaces: the legacy bundle-builder module
|
|
271
271
|
- What: Configurable bundles engine.
|
|
272
|
-
- Files: sections:6 snippets:
|
|
272
|
+
- Files: sections:6 snippets:13 assets:2
|
|
273
273
|
- Config: surface_1_collection_page_enabled, surface_2_pdp_pairs_with_enabled, surface_3_home_stacks_enabled, surface_4_cart_drawer_xsell_enabled, surface_5_pdp_banner_enabled, surface_6_collection_filter_enabled, surface_2_eyebrow, surface_2_heading, surface_3_eyebrow, surface_3_heading, surface_4_eyebrow, surface_4_cta, surface_5_copy_template, cross_supplier_disclosure, home_strip_position
|
|
274
|
-
- Admin steps: install-shopify-bundles + define-bundle-metafields + create-bundle-index-metaobject + create-first-bundle-product + configure-quantity-tier-discount-function + configure-free-gift-discount + create-bundles-collection-page-template + add-home-stacks-section + add-pdp-pairs-with-section + add-pdp-banner-section + verify-cart-drawer-coordination + rebuild-bundle-index
|
|
274
|
+
- Admin steps: install-shopify-bundles + define-bundle-metafields + create-bundle-index-metaobject + create-first-bundle-product + configure-byob-mode + configure-quantity-tier-discount-function + configure-free-gift-discount + create-bundles-collection-page-template + add-home-stacks-section + add-pdp-pairs-with-section + add-pdp-banner-section + verify-cart-drawer-coordination + rebuild-bundle-index
|
|
275
275
|
|
|
276
276
|
### scrolling-ticker
|
|
277
277
|
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
"namespace": "runwell",
|
|
6
6
|
"owner_type": "PRODUCT",
|
|
7
7
|
"definitions": [
|
|
8
|
-
{ "key": "bundle_mode", "name": "Bundle mode", "type": "single_line_text_field", "validations": [{ "name": "choices", "value": "[\"quantity_tiers\",\"multi_product\",\"mix_match\",\"subscription\"]" }] },
|
|
9
|
-
{ "key": "bundle_pricing_model", "name": "Bundle pricing model", "type": "single_line_text_field", "validations": [{ "name": "choices", "value": "[\"tier_quantity\",\"fixed_price\",\"percent_off_subtotal\",\"dollar_off_subtotal\"]" }] },
|
|
8
|
+
{ "key": "bundle_mode", "name": "Bundle mode", "type": "single_line_text_field", "validations": [{ "name": "choices", "value": "[\"quantity_tiers\",\"multi_product\",\"mix_match\",\"byob\",\"subscription\"]" }] },
|
|
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
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." },
|
|
@@ -21,6 +21,12 @@
|
|
|
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_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
|
+
{ "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
|
+
{ "key": "bundle_byob_max_picks", "name": "BYOB max picks", "type": "number_integer", "description": "Mode C only. Maximum allowed selections.", "v1_5": true },
|
|
27
|
+
{ "key": "bundle_byob_layout", "name": "BYOB picker layout", "type": "single_line_text_field", "validations": [{ "name": "choices", "value": "[\"grid\",\"accordion\",\"radio\"]" }], "description": "Mode C only. Default grid.", "v1_5": true },
|
|
28
|
+
{ "key": "bundle_byob_categories", "name": "BYOB categories", "type": "json", "description": "Mode C only. Optional. Array of {label, handles: [product_handle]} to group candidates.", "v1_5": true },
|
|
29
|
+
{ "key": "bundle_byob_required_handles", "name": "BYOB required handles", "type": "list.product_reference", "description": "Mode C only. Pre-selected, not deselectable.", "v1_5": true },
|
|
24
30
|
{ "key": "subscription_enabled", "name": "Subscription enabled (v2)", "type": "boolean", "default": false, "v2_only": true },
|
|
25
31
|
{ "key": "subscription_interval", "name": "Subscription interval (JSON, v2)", "type": "json", "v2_only": true },
|
|
26
32
|
{ "key": "subscription_discount_pct", "name": "Subscription discount percent (v2)", "type": "number_decimal", "v2_only": true },
|
|
@@ -40,6 +46,9 @@
|
|
|
40
46
|
"cross_field_validation": [
|
|
41
47
|
{ "rule": "bundle_quantity_tiers required when bundle_mode == 'quantity_tiers'", "enforced_by": "runwell-shopify qa --bundles" },
|
|
42
48
|
{ "rule": "bundle_components required when bundle_mode in ['multi_product','mix_match']", "enforced_by": "runwell-shopify qa --bundles" },
|
|
49
|
+
{ "rule": "bundle_byob_candidates + bundle_byob_min_picks + bundle_byob_max_picks required when bundle_mode == 'byob'", "enforced_by": "runwell-shopify qa --bundles" },
|
|
50
|
+
{ "rule": "bundle_byob_min_picks <= bundle_byob_max_picks", "enforced_by": "runwell-shopify qa --bundles" },
|
|
51
|
+
{ "rule": "bundle_byob_layout == 'radio' requires bundle_byob_categories", "enforced_by": "runwell-shopify qa --bundles" },
|
|
43
52
|
{ "rule": "bundle_pricing_value shape matches bundle_pricing_model (see spec.md section 2.2)", "enforced_by": "runwell-shopify qa --bundles" },
|
|
44
53
|
{ "rule": "bundle_free_gift_handle required when bundle_free_gift_enabled == true", "enforced_by": "runwell-shopify qa --bundles" }
|
|
45
54
|
]
|
|
@@ -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 {
|
|
@@ -271,17 +271,174 @@
|
|
|
271
271
|
});
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
/* ---------- Mode C BYOB ---------- */
|
|
275
|
+
|
|
276
|
+
function parsePricingValue(raw) {
|
|
277
|
+
if (raw == null || raw === '') return null;
|
|
278
|
+
try { return JSON.parse(raw); } catch (e) { return null; }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function computeByobPrice(subtotalCents, pricingModel, pricingValue) {
|
|
282
|
+
if (pricingModel === 'fixed_bundle_price' || pricingModel === 'fixed_price') {
|
|
283
|
+
const v = typeof pricingValue === 'object' && pricingValue !== null
|
|
284
|
+
? (pricingValue.amount || pricingValue.price || 0)
|
|
285
|
+
: pricingValue;
|
|
286
|
+
return Math.max(0, parseInt(v, 10) || 0);
|
|
287
|
+
}
|
|
288
|
+
if (pricingModel === 'percent_off_subtotal') {
|
|
289
|
+
const pct = typeof pricingValue === 'object' && pricingValue !== null
|
|
290
|
+
? (pricingValue.pct || pricingValue.percent || 0)
|
|
291
|
+
: pricingValue;
|
|
292
|
+
const discounted = subtotalCents * (1 - (parseFloat(pct) || 0) / 100);
|
|
293
|
+
return Math.max(0, Math.round(discounted));
|
|
294
|
+
}
|
|
295
|
+
if (pricingModel === 'dollar_off_subtotal') {
|
|
296
|
+
const off = typeof pricingValue === 'object' && pricingValue !== null
|
|
297
|
+
? (pricingValue.amount || pricingValue.off || 0)
|
|
298
|
+
: pricingValue;
|
|
299
|
+
return Math.max(0, subtotalCents - (parseInt(off, 10) || 0));
|
|
300
|
+
}
|
|
301
|
+
return subtotalCents;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function formatByobMoney(cents) {
|
|
305
|
+
return '$' + (cents / 100).toFixed(2);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function initByob() {
|
|
309
|
+
document
|
|
310
|
+
.querySelectorAll('[data-runwell-byob-picker]')
|
|
311
|
+
.forEach(function (picker) {
|
|
312
|
+
const form = picker.closest('form');
|
|
313
|
+
if (!form) return;
|
|
314
|
+
const root = form.parentElement || form;
|
|
315
|
+
const summary = root.querySelector('[data-runwell-byob-summary]');
|
|
316
|
+
const atc = form.querySelector('[data-runwell-byob-atc]');
|
|
317
|
+
|
|
318
|
+
const layout = picker.dataset.byobLayout;
|
|
319
|
+
const min = parseInt(picker.dataset.byobMin, 10) || 0;
|
|
320
|
+
const max = parseInt(picker.dataset.byobMax, 10) || 0;
|
|
321
|
+
const pricingModel = picker.dataset.byobPricingModel;
|
|
322
|
+
const pricingValue = parsePricingValue(picker.dataset.byobPricingValue);
|
|
323
|
+
|
|
324
|
+
function getSelected() {
|
|
325
|
+
return Array.from(picker.querySelectorAll('.runwell-bundle-system__byob-input:checked'))
|
|
326
|
+
.map(function (input) {
|
|
327
|
+
const label = input.closest('.runwell-bundle-system__byob-candidate');
|
|
328
|
+
return {
|
|
329
|
+
handle: label.dataset.handle,
|
|
330
|
+
price: parseInt(label.dataset.price, 10) || 0,
|
|
331
|
+
variantId: label.dataset.variantId,
|
|
332
|
+
category: label.dataset.category || null,
|
|
333
|
+
input: input
|
|
334
|
+
};
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function updateAccordionCounts() {
|
|
339
|
+
if (layout !== 'accordion') return;
|
|
340
|
+
picker.querySelectorAll('.runwell-bundle-system__byob-accordion').forEach(function (acc) {
|
|
341
|
+
const count = acc.querySelectorAll('.runwell-bundle-system__byob-input:checked').length;
|
|
342
|
+
const badge = acc.querySelector('[data-runwell-byob-cat-count]');
|
|
343
|
+
if (badge) badge.textContent = count;
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function enforceMax(selected) {
|
|
348
|
+
if (layout === 'radio') return;
|
|
349
|
+
if (selected.length <= max) return;
|
|
350
|
+
const overflow = selected.slice(max);
|
|
351
|
+
overflow.forEach(function (s) {
|
|
352
|
+
if (!s.input.disabled) s.input.checked = false;
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function render() {
|
|
357
|
+
let selected = getSelected();
|
|
358
|
+
enforceMax(selected);
|
|
359
|
+
selected = getSelected();
|
|
360
|
+
|
|
361
|
+
const count = selected.length;
|
|
362
|
+
const subtotal = selected.reduce(function (acc, s) { return acc + s.price; }, 0);
|
|
363
|
+
const total = computeByobPrice(subtotal, pricingModel, pricingValue);
|
|
364
|
+
const savings = Math.max(0, subtotal - total);
|
|
365
|
+
|
|
366
|
+
if (summary) {
|
|
367
|
+
const selEl = summary.querySelector('[data-runwell-byob-selected]');
|
|
368
|
+
const totalEl = summary.querySelector('[data-runwell-byob-total]');
|
|
369
|
+
const subEl = summary.querySelector('[data-runwell-byob-subtotal]');
|
|
370
|
+
const subAmt = summary.querySelector('[data-runwell-byob-subtotal-amount]');
|
|
371
|
+
const savEl = summary.querySelector('[data-runwell-byob-savings]');
|
|
372
|
+
const savAmt = summary.querySelector('[data-runwell-byob-savings-amount]');
|
|
373
|
+
const helper = summary.querySelector('[data-runwell-byob-helper]');
|
|
374
|
+
|
|
375
|
+
if (selEl) selEl.textContent = count;
|
|
376
|
+
if (totalEl) totalEl.textContent = formatByobMoney(total);
|
|
377
|
+
const isFixed = pricingModel === 'fixed_bundle_price' || pricingModel === 'fixed_price';
|
|
378
|
+
if (savings > 0 && !isFixed) {
|
|
379
|
+
if (subEl) { subEl.hidden = false; if (subAmt) subAmt.textContent = formatByobMoney(subtotal); }
|
|
380
|
+
if (savEl) { savEl.hidden = false; if (savAmt) savAmt.textContent = formatByobMoney(savings); }
|
|
381
|
+
} else {
|
|
382
|
+
if (subEl) subEl.hidden = true;
|
|
383
|
+
if (savEl) savEl.hidden = true;
|
|
384
|
+
}
|
|
385
|
+
if (helper) helper.style.display = count >= min ? 'none' : '';
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (atc) {
|
|
389
|
+
const ok = count >= min && count <= max;
|
|
390
|
+
atc.disabled = !ok;
|
|
391
|
+
}
|
|
392
|
+
updateAccordionCounts();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
picker.addEventListener('change', function (e) {
|
|
396
|
+
if (!e.target.matches('.runwell-bundle-system__byob-input')) return;
|
|
397
|
+
render();
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
form.addEventListener('submit', function (e) {
|
|
401
|
+
const selected = getSelected();
|
|
402
|
+
if (selected.length < min || selected.length > max) {
|
|
403
|
+
e.preventDefault();
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
e.preventDefault();
|
|
407
|
+
const items = selected.map(function (s) {
|
|
408
|
+
return { id: parseInt(s.variantId, 10), quantity: 1 };
|
|
409
|
+
});
|
|
410
|
+
fetch('/cart/add.js', {
|
|
411
|
+
method: 'POST',
|
|
412
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
413
|
+
body: JSON.stringify({ items: items })
|
|
414
|
+
})
|
|
415
|
+
.then(function (r) { return r.json(); })
|
|
416
|
+
.then(function () {
|
|
417
|
+
document.dispatchEvent(new CustomEvent('cart:refresh'));
|
|
418
|
+
window.location.href = '/cart';
|
|
419
|
+
})
|
|
420
|
+
.catch(function () {
|
|
421
|
+
if (atc) atc.disabled = false;
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
render();
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
274
429
|
if (document.readyState === 'loading') {
|
|
275
430
|
document.addEventListener('DOMContentLoaded', function () {
|
|
276
431
|
initFomo();
|
|
277
432
|
initTierSync();
|
|
278
433
|
initFilterChip();
|
|
279
434
|
initCartXsell();
|
|
435
|
+
initByob();
|
|
280
436
|
});
|
|
281
437
|
} else {
|
|
282
438
|
initFomo();
|
|
283
439
|
initTierSync();
|
|
284
440
|
initFilterChip();
|
|
285
441
|
initCartXsell();
|
|
442
|
+
initByob();
|
|
286
443
|
}
|
|
287
444
|
})();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "runwell-bundle-system",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"category": "catalog",
|
|
5
5
|
"source": "runwell",
|
|
6
6
|
"base": "bundle-system",
|
|
@@ -23,7 +23,12 @@
|
|
|
23
23
|
"snippets/runwell-bundle-cross-supplier.liquid",
|
|
24
24
|
"snippets/runwell-bundle-fomo.liquid",
|
|
25
25
|
"snippets/runwell-bundle-free-gift.liquid",
|
|
26
|
-
"snippets/runwell-bundle-data.liquid"
|
|
26
|
+
"snippets/runwell-bundle-data.liquid",
|
|
27
|
+
"snippets/runwell-bundle-byob-picker.liquid",
|
|
28
|
+
"snippets/runwell-bundle-byob-picker-grid.liquid",
|
|
29
|
+
"snippets/runwell-bundle-byob-picker-accordion.liquid",
|
|
30
|
+
"snippets/runwell-bundle-byob-picker-radio.liquid",
|
|
31
|
+
"snippets/runwell-bundle-byob-summary.liquid"
|
|
27
32
|
],
|
|
28
33
|
"assets": [
|
|
29
34
|
"assets/runwell-bundle-system.css",
|
|
@@ -74,6 +79,12 @@
|
|
|
74
79
|
"url": "https://admin.shopify.com/store/{store_handle}/products/new",
|
|
75
80
|
"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
81
|
},
|
|
82
|
+
{
|
|
83
|
+
"id": "configure-byob-mode",
|
|
84
|
+
"label": "Configure a BYOB (Mode C) bundle product",
|
|
85
|
+
"url": "https://admin.shopify.com/store/{store_handle}/products",
|
|
86
|
+
"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."
|
|
87
|
+
},
|
|
77
88
|
{
|
|
78
89
|
"id": "configure-quantity-tier-discount-function",
|
|
79
90
|
"label": "Create a Shopify Functions discount for quantity-tier mode (Mode A only)",
|
|
@@ -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: ___________________
|
|
@@ -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 -%}
|
|
@@ -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 -%}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{%- comment -%}
|
|
2
|
+
runwell-bundle-system: runwell-bundle-byob-picker-grid.liquid.
|
|
3
|
+
Mode C, default layout. Flat checkbox grid of all candidates. Min/max
|
|
4
|
+
picks enforced via JS.
|
|
5
|
+
|
|
6
|
+
Inputs:
|
|
7
|
+
bundle_byob_candidates, bundle_byob_min_picks, bundle_byob_max_picks,
|
|
8
|
+
bundle_byob_required_handles, bundle_pricing_model, bundle_pricing_value.
|
|
9
|
+
{%- endcomment -%}
|
|
10
|
+
|
|
11
|
+
{%- if bundle_byob_candidates and bundle_byob_candidates.size > 0 -%}
|
|
12
|
+
{%- assign required_handles = '' -%}
|
|
13
|
+
{%- if bundle_byob_required_handles -%}
|
|
14
|
+
{%- capture required_handles -%}
|
|
15
|
+
{%- for r in bundle_byob_required_handles -%}{{ r.handle }},{%- endfor -%}
|
|
16
|
+
{%- endcapture -%}
|
|
17
|
+
{%- endif -%}
|
|
18
|
+
|
|
19
|
+
<div
|
|
20
|
+
class="runwell-bundle-system__byob-picker runwell-bundle-system__byob-picker--grid"
|
|
21
|
+
data-runwell-byob-picker
|
|
22
|
+
data-byob-layout="grid"
|
|
23
|
+
data-byob-min="{{ bundle_byob_min_picks }}"
|
|
24
|
+
data-byob-max="{{ bundle_byob_max_picks }}"
|
|
25
|
+
data-byob-pricing-model="{{ bundle_pricing_model }}"
|
|
26
|
+
data-byob-pricing-value="{{ bundle_pricing_value | json | escape }}"
|
|
27
|
+
>
|
|
28
|
+
{%- for candidate in bundle_byob_candidates -%}
|
|
29
|
+
{%- assign is_required = false -%}
|
|
30
|
+
{%- if required_handles contains candidate.handle -%}{%- assign is_required = true -%}{%- endif -%}
|
|
31
|
+
{%- assign is_available = candidate.available | default: true -%}
|
|
32
|
+
|
|
33
|
+
<label
|
|
34
|
+
class="runwell-bundle-system__byob-candidate{% if is_required %} runwell-bundle-system__byob-candidate--required{% endif %}{% unless is_available %} runwell-bundle-system__byob-candidate--unavailable{% endunless %}"
|
|
35
|
+
data-handle="{{ candidate.handle }}"
|
|
36
|
+
data-price="{{ candidate.price }}"
|
|
37
|
+
data-variant-id="{{ candidate.selected_or_first_available_variant.id }}"
|
|
38
|
+
>
|
|
39
|
+
<input
|
|
40
|
+
type="checkbox"
|
|
41
|
+
class="runwell-bundle-system__byob-input"
|
|
42
|
+
name="byob-pick"
|
|
43
|
+
value="{{ candidate.handle }}"
|
|
44
|
+
{% if is_required %}checked disabled{% endif %}
|
|
45
|
+
{% unless is_available %}disabled{% endunless %}
|
|
46
|
+
>
|
|
47
|
+
<span class="runwell-bundle-system__byob-candidate-media">
|
|
48
|
+
{%- if candidate.featured_image -%}
|
|
49
|
+
{{ candidate.featured_image | image_url: width: 240 | image_tag:
|
|
50
|
+
width: 120,
|
|
51
|
+
height: 120,
|
|
52
|
+
loading: 'lazy',
|
|
53
|
+
class: 'runwell-bundle-system__byob-candidate-thumb',
|
|
54
|
+
alt: candidate.title
|
|
55
|
+
}}
|
|
56
|
+
{%- endif -%}
|
|
57
|
+
</span>
|
|
58
|
+
<span class="runwell-bundle-system__byob-candidate-body">
|
|
59
|
+
<span class="runwell-bundle-system__byob-candidate-title">{{ candidate.title }}</span>
|
|
60
|
+
<span class="runwell-bundle-system__byob-candidate-price">{{ candidate.price | money }}</span>
|
|
61
|
+
{%- if is_required -%}
|
|
62
|
+
<span class="runwell-bundle-system__byob-candidate-badge">Always included</span>
|
|
63
|
+
{%- elsif is_available == false -%}
|
|
64
|
+
<span class="runwell-bundle-system__byob-candidate-badge">Sold out</span>
|
|
65
|
+
{%- endif -%}
|
|
66
|
+
</span>
|
|
67
|
+
</label>
|
|
68
|
+
{%- endfor -%}
|
|
69
|
+
</div>
|
|
70
|
+
{%- else -%}
|
|
71
|
+
<p class="runwell-bundle-system__empty">No BYOB candidates configured for this bundle.</p>
|
|
72
|
+
{%- endif -%}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{%- comment -%}
|
|
2
|
+
runwell-bundle-system: runwell-bundle-byob-picker-radio.liquid.
|
|
3
|
+
Mode C, one-pick-per-category layout. Total picks equals category count.
|
|
4
|
+
Each category renders as a radio group; total picks across categories
|
|
5
|
+
is the category count exactly.
|
|
6
|
+
|
|
7
|
+
Inputs:
|
|
8
|
+
bundle_byob_candidates, bundle_byob_categories ([{label, handles}]),
|
|
9
|
+
bundle_byob_required_handles, bundle_pricing_model, bundle_pricing_value.
|
|
10
|
+
{%- endcomment -%}
|
|
11
|
+
|
|
12
|
+
{%- if bundle_byob_categories and bundle_byob_categories.size > 0 -%}
|
|
13
|
+
{%- assign required_handles = '' -%}
|
|
14
|
+
{%- if bundle_byob_required_handles -%}
|
|
15
|
+
{%- capture required_handles -%}
|
|
16
|
+
{%- for r in bundle_byob_required_handles -%}{{ r.handle }},{%- endfor -%}
|
|
17
|
+
{%- endcapture -%}
|
|
18
|
+
{%- endif -%}
|
|
19
|
+
{%- assign cat_count = bundle_byob_categories.size -%}
|
|
20
|
+
|
|
21
|
+
<div
|
|
22
|
+
class="runwell-bundle-system__byob-picker runwell-bundle-system__byob-picker--radio"
|
|
23
|
+
data-runwell-byob-picker
|
|
24
|
+
data-byob-layout="radio"
|
|
25
|
+
data-byob-min="{{ cat_count }}"
|
|
26
|
+
data-byob-max="{{ cat_count }}"
|
|
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
|
+
{%- assign cat_idx = forloop.index -%}
|
|
32
|
+
<fieldset class="runwell-bundle-system__byob-radio-group">
|
|
33
|
+
<legend class="runwell-bundle-system__byob-radio-legend">{{ category.label }}</legend>
|
|
34
|
+
{%- for handle in category.handles -%}
|
|
35
|
+
{%- assign candidate = all_products[handle] -%}
|
|
36
|
+
{%- if candidate != blank -%}
|
|
37
|
+
{%- assign is_required = false -%}
|
|
38
|
+
{%- if required_handles contains handle -%}{%- assign is_required = true -%}{%- endif -%}
|
|
39
|
+
<label
|
|
40
|
+
class="runwell-bundle-system__byob-candidate runwell-bundle-system__byob-candidate--radio{% if is_required %} runwell-bundle-system__byob-candidate--required{% endif %}"
|
|
41
|
+
data-handle="{{ candidate.handle }}"
|
|
42
|
+
data-price="{{ candidate.price }}"
|
|
43
|
+
data-variant-id="{{ candidate.selected_or_first_available_variant.id }}"
|
|
44
|
+
data-category="{{ category.label | escape }}"
|
|
45
|
+
>
|
|
46
|
+
<input
|
|
47
|
+
type="radio"
|
|
48
|
+
class="runwell-bundle-system__byob-input"
|
|
49
|
+
name="byob-pick-cat-{{ cat_idx }}"
|
|
50
|
+
value="{{ candidate.handle }}"
|
|
51
|
+
{% if is_required or forloop.first %}checked{% endif %}
|
|
52
|
+
{% unless candidate.available %}disabled{% endunless %}
|
|
53
|
+
>
|
|
54
|
+
<span class="runwell-bundle-system__byob-candidate-media">
|
|
55
|
+
{%- if candidate.featured_image -%}
|
|
56
|
+
{{ candidate.featured_image | image_url: width: 240 | image_tag:
|
|
57
|
+
width: 80,
|
|
58
|
+
height: 80,
|
|
59
|
+
loading: 'lazy',
|
|
60
|
+
class: 'runwell-bundle-system__byob-candidate-thumb',
|
|
61
|
+
alt: candidate.title
|
|
62
|
+
}}
|
|
63
|
+
{%- endif -%}
|
|
64
|
+
</span>
|
|
65
|
+
<span class="runwell-bundle-system__byob-candidate-body">
|
|
66
|
+
<span class="runwell-bundle-system__byob-candidate-title">{{ candidate.title }}</span>
|
|
67
|
+
<span class="runwell-bundle-system__byob-candidate-price">{{ candidate.price | money }}</span>
|
|
68
|
+
</span>
|
|
69
|
+
</label>
|
|
70
|
+
{%- endif -%}
|
|
71
|
+
{%- endfor -%}
|
|
72
|
+
</fieldset>
|
|
73
|
+
{%- endfor -%}
|
|
74
|
+
</div>
|
|
75
|
+
{%- else -%}
|
|
76
|
+
<p class="runwell-bundle-system__empty">Radio layout requires bundle_byob_categories.</p>
|
|
77
|
+
{%- endif -%}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{%- comment -%}
|
|
2
|
+
runwell-bundle-system: runwell-bundle-byob-picker.liquid.
|
|
3
|
+
Mode C router. Reads bundle_byob_layout and delegates to one of
|
|
4
|
+
three layout snippets:
|
|
5
|
+
- grid (default)
|
|
6
|
+
- accordion
|
|
7
|
+
- radio (one pick per category)
|
|
8
|
+
|
|
9
|
+
Inputs (all forwarded to the layout snippet):
|
|
10
|
+
product, bundle_pricing_model, bundle_pricing_value,
|
|
11
|
+
bundle_byob_candidates, bundle_byob_min_picks, bundle_byob_max_picks,
|
|
12
|
+
bundle_byob_layout, bundle_byob_categories, bundle_byob_required_handles.
|
|
13
|
+
|
|
14
|
+
Fallback: if layout == 'radio' but no categories are set, falls back
|
|
15
|
+
to grid and emits a console warning client side.
|
|
16
|
+
{%- endcomment -%}
|
|
17
|
+
|
|
18
|
+
{%- assign layout = bundle_byob_layout | default: 'grid' -%}
|
|
19
|
+
{%- if layout == 'radio' and bundle_byob_categories == blank -%}
|
|
20
|
+
{%- assign layout = 'grid' -%}
|
|
21
|
+
{%- endif -%}
|
|
22
|
+
|
|
23
|
+
{% render 'runwell-bundle-byob-summary',
|
|
24
|
+
bundle_pricing_model: bundle_pricing_model,
|
|
25
|
+
bundle_pricing_value: bundle_pricing_value,
|
|
26
|
+
bundle_byob_min_picks: bundle_byob_min_picks,
|
|
27
|
+
bundle_byob_max_picks: bundle_byob_max_picks
|
|
28
|
+
%}
|
|
29
|
+
|
|
30
|
+
{%- form 'product', product, class: 'runwell-bundle-system__form runwell-bundle-system__byob-form', novalidate: 'novalidate' -%}
|
|
31
|
+
{%- if layout == 'grid' -%}
|
|
32
|
+
{% render 'runwell-bundle-byob-picker-grid',
|
|
33
|
+
bundle_byob_candidates: bundle_byob_candidates,
|
|
34
|
+
bundle_byob_min_picks: bundle_byob_min_picks,
|
|
35
|
+
bundle_byob_max_picks: bundle_byob_max_picks,
|
|
36
|
+
bundle_byob_required_handles: bundle_byob_required_handles,
|
|
37
|
+
bundle_pricing_model: bundle_pricing_model,
|
|
38
|
+
bundle_pricing_value: bundle_pricing_value
|
|
39
|
+
%}
|
|
40
|
+
{%- elsif layout == 'accordion' -%}
|
|
41
|
+
{% render 'runwell-bundle-byob-picker-accordion',
|
|
42
|
+
bundle_byob_candidates: bundle_byob_candidates,
|
|
43
|
+
bundle_byob_min_picks: bundle_byob_min_picks,
|
|
44
|
+
bundle_byob_max_picks: bundle_byob_max_picks,
|
|
45
|
+
bundle_byob_categories: bundle_byob_categories,
|
|
46
|
+
bundle_byob_required_handles: bundle_byob_required_handles,
|
|
47
|
+
bundle_pricing_model: bundle_pricing_model,
|
|
48
|
+
bundle_pricing_value: bundle_pricing_value
|
|
49
|
+
%}
|
|
50
|
+
{%- elsif layout == 'radio' -%}
|
|
51
|
+
{% render 'runwell-bundle-byob-picker-radio',
|
|
52
|
+
bundle_byob_candidates: bundle_byob_candidates,
|
|
53
|
+
bundle_byob_categories: bundle_byob_categories,
|
|
54
|
+
bundle_byob_required_handles: bundle_byob_required_handles,
|
|
55
|
+
bundle_pricing_model: bundle_pricing_model,
|
|
56
|
+
bundle_pricing_value: bundle_pricing_value
|
|
57
|
+
%}
|
|
58
|
+
{%- endif -%}
|
|
59
|
+
|
|
60
|
+
<input type="hidden" name="id" value="{{ product.selected_or_first_available_variant.id }}">
|
|
61
|
+
|
|
62
|
+
<button
|
|
63
|
+
type="submit"
|
|
64
|
+
name="add"
|
|
65
|
+
class="runwell-bundle-system__atc button button--primary button--full-width"
|
|
66
|
+
data-runwell-byob-atc
|
|
67
|
+
disabled
|
|
68
|
+
>
|
|
69
|
+
<span class="runwell-bundle-system__atc-label">Add to cart</span>
|
|
70
|
+
</button>
|
|
71
|
+
{%- endform -%}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{%- comment -%}
|
|
2
|
+
runwell-bundle-system: runwell-bundle-byob-summary.liquid.
|
|
3
|
+
Live counter + price for Mode C BYOB. Numbers update from JS on every
|
|
4
|
+
selection change. Static fallback on first render shows zeros.
|
|
5
|
+
|
|
6
|
+
Inputs:
|
|
7
|
+
bundle_pricing_model, bundle_pricing_value,
|
|
8
|
+
bundle_byob_min_picks, bundle_byob_max_picks.
|
|
9
|
+
{%- endcomment -%}
|
|
10
|
+
|
|
11
|
+
<div
|
|
12
|
+
class="runwell-bundle-system__byob-summary"
|
|
13
|
+
data-runwell-byob-summary
|
|
14
|
+
data-byob-min="{{ bundle_byob_min_picks }}"
|
|
15
|
+
data-byob-max="{{ bundle_byob_max_picks }}"
|
|
16
|
+
data-byob-pricing-model="{{ bundle_pricing_model }}"
|
|
17
|
+
data-byob-pricing-value="{{ bundle_pricing_value | json | escape }}"
|
|
18
|
+
>
|
|
19
|
+
<p class="runwell-bundle-system__byob-counter" data-runwell-byob-counter>
|
|
20
|
+
<span data-runwell-byob-selected>0</span>
|
|
21
|
+
<span class="runwell-bundle-system__byob-counter-of">of</span>
|
|
22
|
+
<span data-runwell-byob-target>{{ bundle_byob_max_picks }}</span>
|
|
23
|
+
<span class="runwell-bundle-system__byob-counter-label">selected</span>
|
|
24
|
+
</p>
|
|
25
|
+
|
|
26
|
+
<div class="runwell-bundle-system__byob-pricing">
|
|
27
|
+
<span class="runwell-bundle-system__byob-pricing-subtotal" data-runwell-byob-subtotal hidden>
|
|
28
|
+
<s data-runwell-byob-subtotal-amount>{{ 0 | money }}</s>
|
|
29
|
+
</span>
|
|
30
|
+
<span class="runwell-bundle-system__byob-pricing-current" data-runwell-byob-total>{{ 0 | money }}</span>
|
|
31
|
+
<span class="runwell-bundle-system__byob-pricing-badge" data-runwell-byob-savings hidden>
|
|
32
|
+
Save <span data-runwell-byob-savings-amount></span>
|
|
33
|
+
</span>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<p class="runwell-bundle-system__byob-helper" data-runwell-byob-helper>
|
|
37
|
+
Pick at least <strong>{{ bundle_byob_min_picks }}</strong>{% if bundle_byob_max_picks != bundle_byob_min_picks %}, up to <strong>{{ bundle_byob_max_picks }}</strong>{% endif %} to continue.
|
|
38
|
+
</p>
|
|
39
|
+
</div>
|
|
@@ -32,6 +32,14 @@
|
|
|
32
32
|
{%- assign bundle_supplier_count = mf.bundle_supplier_count.value | default: 1 -%}
|
|
33
33
|
{%- assign bundle_savings_pct_pre = mf.bundle_savings_pct.value -%}
|
|
34
34
|
|
|
35
|
+
{%- comment -%} Mode C BYOB metafields (BS-201..BS-203). {%- endcomment -%}
|
|
36
|
+
{%- assign bundle_byob_candidates = mf.bundle_byob_candidates.value -%}
|
|
37
|
+
{%- assign bundle_byob_min_picks = mf.bundle_byob_min_picks.value | default: 1 -%}
|
|
38
|
+
{%- assign bundle_byob_max_picks = mf.bundle_byob_max_picks.value | default: 3 -%}
|
|
39
|
+
{%- assign bundle_byob_layout = mf.bundle_byob_layout.value | default: 'grid' -%}
|
|
40
|
+
{%- assign bundle_byob_categories = mf.bundle_byob_categories.value -%}
|
|
41
|
+
{%- assign bundle_byob_required_handles = mf.bundle_byob_required_handles.value -%}
|
|
42
|
+
|
|
35
43
|
{%- comment -%} Compute subtotal for multi_product mode by walking components. {%- endcomment -%}
|
|
36
44
|
{%- assign bundle_subtotal = 0 -%}
|
|
37
45
|
{%- if bundle_components and bundle_components.size > 0 -%}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runwell/shopify-toolkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Reusable Shopify theme modules from Runwell. Replaces typically app-driven features (reviews, wishlist, urgency, FAQ, post-purchase upsell, exit popups, free-ship progress, sticky ATC, testimonials, badges, bundles) with native Liquid + JS + CSS that ship across multiple client themes via a config-driven sync CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|