@runwell/shopify-toolkit 0.18.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.
Files changed (37) hide show
  1. package/bin/runwell-shopify +10 -1
  2. package/lib/init.js +13 -2
  3. package/lib/list.js +22 -9
  4. package/lib/qa-bundles.js +117 -0
  5. package/lib/qa.js +147 -13
  6. package/modules/INDEX.md +14 -5
  7. package/modules/bundle-builder/README.md +6 -1
  8. package/modules/bundle-builder/module.json +5 -1
  9. package/modules/cart-cross-sell/snippets/runwell-cart-xsell.liquid +16 -0
  10. package/modules/runwell-bundle-system/README.md +35 -0
  11. package/modules/runwell-bundle-system/admin-metafields.json +55 -0
  12. package/modules/runwell-bundle-system/assets/runwell-bundle-system.css +1107 -0
  13. package/modules/runwell-bundle-system/assets/runwell-bundle-system.js +444 -0
  14. package/modules/runwell-bundle-system/module.json +137 -0
  15. package/modules/runwell-bundle-system/qa/mobile-checklist.md +105 -0
  16. package/modules/runwell-bundle-system/qa/v1.5-mobile-qa-checklist.md +103 -0
  17. package/modules/runwell-bundle-system/sections/runwell-bundle-cart-xsell.liquid +59 -0
  18. package/modules/runwell-bundle-system/sections/runwell-bundle-collection.liquid +121 -0
  19. package/modules/runwell-bundle-system/sections/runwell-bundle-home-stacks.liquid +77 -0
  20. package/modules/runwell-bundle-system/sections/runwell-bundle-pdp-banner.liquid +50 -0
  21. package/modules/runwell-bundle-system/sections/runwell-bundle-pdp-pairs-with.liquid +72 -0
  22. package/modules/runwell-bundle-system/sections/runwell-bundle-pdp.liquid +117 -0
  23. package/modules/runwell-bundle-system/settings.json +25 -0
  24. package/modules/runwell-bundle-system/snippets/runwell-bundle-byob-picker-accordion.liquid +84 -0
  25. package/modules/runwell-bundle-system/snippets/runwell-bundle-byob-picker-grid.liquid +72 -0
  26. package/modules/runwell-bundle-system/snippets/runwell-bundle-byob-picker-radio.liquid +77 -0
  27. package/modules/runwell-bundle-system/snippets/runwell-bundle-byob-picker.liquid +71 -0
  28. package/modules/runwell-bundle-system/snippets/runwell-bundle-byob-summary.liquid +39 -0
  29. package/modules/runwell-bundle-system/snippets/runwell-bundle-card.liquid +70 -0
  30. package/modules/runwell-bundle-system/snippets/runwell-bundle-cross-supplier.liquid +18 -0
  31. package/modules/runwell-bundle-system/snippets/runwell-bundle-data.liquid +75 -0
  32. package/modules/runwell-bundle-system/snippets/runwell-bundle-fomo.liquid +32 -0
  33. package/modules/runwell-bundle-system/snippets/runwell-bundle-free-gift.liquid +34 -0
  34. package/modules/runwell-bundle-system/snippets/runwell-bundle-multi-product.liquid +86 -0
  35. package/modules/runwell-bundle-system/snippets/runwell-bundle-pricing.liquid +30 -0
  36. package/modules/runwell-bundle-system/snippets/runwell-bundle-quantity-tiers.liquid +73 -0
  37. package/package.json +1 -1
@@ -0,0 +1,117 @@
1
+ {%- comment -%}
2
+ runwell-bundle-system: runwell-bundle-pdp.liquid.
3
+ Bundle PDP section. Used on templates/product.bundle.json (auto-assigned
4
+ to products with the runwell.bundle_mode metafield set). Routes to a
5
+ mode-specific snippet based on bundle_mode.
6
+
7
+ See _clients/capital-v/lushi/specs/bundle-system/spec.md sections 3, 5.
8
+ {%- endcomment -%}
9
+
10
+ {%- comment -%} v2: render runwell-subscription-picker here when bundle_subscription_enabled. Per BS-13 the subscription_* metafields ship in v1 inactive so v2 wires without a schema migration. {%- endcomment -%}
11
+
12
+ {% render 'runwell-bundle-data', product: product %}
13
+
14
+ <section
15
+ class="runwell-bundle-system runwell-bundle-system--mode-{{ bundle_mode | replace: '_', '-' }} color-{{ section.settings.color_scheme }}"
16
+ style="padding-top: {{ section.settings.padding_top }}px; padding-bottom: {{ section.settings.padding_bottom }}px;"
17
+ >
18
+ <div class="runwell-bundle-system__inner page-width">
19
+ <div class="runwell-bundle-system__gallery">
20
+ {%- if product.featured_image -%}
21
+ {{ product.featured_image | image_url: width: 800 | image_tag:
22
+ loading: 'eager',
23
+ width: product.featured_image.width,
24
+ height: product.featured_image.height,
25
+ sizes: '(min-width: 1024px) 50vw, 100vw',
26
+ class: 'runwell-bundle-system__gallery-image'
27
+ }}
28
+ {%- endif -%}
29
+ </div>
30
+
31
+ <div class="runwell-bundle-system__details">
32
+ {%- if section.settings.sale_prefix != blank -%}
33
+ <p class="runwell-bundle-system__sale-prefix">{{ section.settings.sale_prefix }}</p>
34
+ {%- endif -%}
35
+
36
+ <h1 class="runwell-bundle-system__title">{{ product.title }}</h1>
37
+
38
+ {%- if product.description != blank -%}
39
+ <div class="runwell-bundle-system__description rte">{{ product.description }}</div>
40
+ {%- endif -%}
41
+
42
+ {%- if bundle_mode == 'quantity_tiers' -%}
43
+ {% render 'runwell-bundle-quantity-tiers',
44
+ product: product,
45
+ bundle_mode: bundle_mode,
46
+ bundle_pricing_model: bundle_pricing_model,
47
+ bundle_quantity_tiers: bundle_quantity_tiers,
48
+ section: section
49
+ %}
50
+ {%- elsif bundle_mode == 'multi_product' or bundle_mode == 'mix_match' -%}
51
+ {% render 'runwell-bundle-multi-product',
52
+ product: product,
53
+ bundle_mode: bundle_mode,
54
+ bundle_pricing_model: bundle_pricing_model,
55
+ bundle_components: bundle_components,
56
+ bundle_subtotal: bundle_subtotal,
57
+ bundle_price: bundle_price,
58
+ bundle_savings_amount: bundle_savings_amount,
59
+ bundle_savings_pct: bundle_savings_pct
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
+ %}
73
+ {%- endif -%}
74
+
75
+ {%- if bundle_fomo_mode != 'none' and bundle_fomo_mode != blank -%}
76
+ {% render 'runwell-bundle-fomo',
77
+ bundle_fomo_mode: bundle_fomo_mode,
78
+ bundle_fomo_cycle_days: bundle_fomo_cycle_days,
79
+ bundle_fomo_stock_count: bundle_fomo_stock_count
80
+ %}
81
+ {%- endif -%}
82
+
83
+ {%- if bundle_free_gift_enabled -%}
84
+ {% render 'runwell-bundle-free-gift',
85
+ bundle_free_gift_handle: bundle_free_gift_handle
86
+ %}
87
+ {%- endif -%}
88
+
89
+ {%- if bundle_cross_supplier and bundle_supplier_count > 1 -%}
90
+ {% render 'runwell-bundle-cross-supplier',
91
+ bundle_supplier_count: bundle_supplier_count
92
+ %}
93
+ {%- endif -%}
94
+ </div>
95
+ </div>
96
+ </section>
97
+
98
+ {{ 'runwell-bundle-system.css' | asset_url | stylesheet_tag }}
99
+ <script src="{{ 'runwell-bundle-system.js' | asset_url }}" defer="defer"></script>
100
+
101
+ {% schema %}
102
+ {
103
+ "name": "Bundle PDP",
104
+ "tag": "section",
105
+ "class": "section-runwell-bundle-pdp",
106
+ "settings": [
107
+ { "type": "text", "id": "sale_prefix", "label": "Sale prefix (above title)", "default": "Limited time offer" },
108
+ { "type": "color_scheme", "id": "color_scheme", "label": "Color scheme", "default": "scheme-1" },
109
+ { "type": "range", "id": "padding_top", "label": "Top padding", "min": 0, "max": 100, "step": 4, "default": 40, "unit": "px" },
110
+ { "type": "range", "id": "padding_bottom", "label": "Bottom padding", "min": 0, "max": 100, "step": 4, "default": 40, "unit": "px" }
111
+ ],
112
+ "presets": [
113
+ { "name": "Bundle PDP", "category": "Runwell" }
114
+ ],
115
+ "templates": ["product.bundle"]
116
+ }
117
+ {% endschema %}
@@ -0,0 +1,25 @@
1
+ {
2
+ "$comment": "Theme customizer settings patch. runwell-shopify sync appends this group to tenant config/settings_schema.json. Site-wide defaults; section-level overrides live in each section's schema.",
3
+ "spec_ref": "_clients/capital-v/lushi/specs/bundle-system/spec.md#10-settings-schema-for-theme-customizer",
4
+ "name": "Bundle system",
5
+ "settings": [
6
+ { "type": "header", "content": "Display surfaces" },
7
+ { "type": "checkbox", "id": "bundle_system__surface_1_collection_page_enabled", "label": "/bundles collection page", "default": true },
8
+ { "type": "checkbox", "id": "bundle_system__surface_2_pdp_pairs_with_enabled", "label": "PDP 'Pairs well with' widget", "default": true },
9
+ { "type": "checkbox", "id": "bundle_system__surface_3_home_stacks_enabled", "label": "Home page curated stacks", "default": true },
10
+ { "type": "checkbox", "id": "bundle_system__surface_4_cart_drawer_xsell_enabled", "label": "Cart drawer bundle cross-sell", "default": true },
11
+ { "type": "checkbox", "id": "bundle_system__surface_5_pdp_banner_enabled", "label": "PDP bundle banner", "default": true },
12
+ { "type": "checkbox", "id": "bundle_system__surface_6_collection_filter_enabled", "label": "Collection page bundles filter", "default": false },
13
+ { "type": "header", "content": "Default copy" },
14
+ { "type": "text", "id": "bundle_system__surface_2_eyebrow", "label": "Surface 2 eyebrow", "default": "Pairs well with" },
15
+ { "type": "text", "id": "bundle_system__surface_2_heading", "label": "Surface 2 heading", "default": "Complete the routine" },
16
+ { "type": "text", "id": "bundle_system__surface_3_eyebrow", "label": "Surface 3 eyebrow", "default": "Editor's picks" },
17
+ { "type": "text", "id": "bundle_system__surface_3_heading", "label": "Surface 3 heading", "default": "Curated stacks" },
18
+ { "type": "text", "id": "bundle_system__surface_4_eyebrow", "label": "Surface 4 eyebrow", "default": "Complete the stack" },
19
+ { "type": "text", "id": "bundle_system__surface_4_cta", "label": "Surface 4 CTA", "default": "Add bundle and save" },
20
+ { "type": "text", "id": "bundle_system__surface_5_copy_template", "label": "Surface 5 copy template", "default": "Save {savings_pct}% when bundled" },
21
+ { "type": "text", "id": "bundle_system__cross_supplier_disclosure", "label": "Cross-supplier disclosure", "default": "This bundle ships in {n} packages." },
22
+ { "type": "header", "content": "Layout" },
23
+ { "type": "range", "id": "bundle_system__home_strip_position", "label": "Home strip position (1-10)", "min": 1, "max": 10, "step": 1, "default": 3 }
24
+ ]
25
+ }
@@ -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>
@@ -0,0 +1,70 @@
1
+ {%- comment -%}
2
+ runwell-bundle-system: runwell-bundle-card.liquid.
3
+ Shared bundle card UI used by Surfaces 1, 2, 3, 4, 6.
4
+
5
+ Inputs:
6
+ bundle_product (the bundle product to render; required)
7
+ variant ('compact' | 'default' | 'hero'; default 'default')
8
+
9
+ Renders a self-contained card linking to /products/<bundle_handle>.
10
+ Loads the bundle's metafields locally via runwell-bundle-data so the
11
+ card has access to savings, supplier count, and component count.
12
+ {%- endcomment -%}
13
+
14
+ {%- if bundle_product != blank -%}
15
+ {%- assign card_variant = variant | default: 'default' -%}
16
+ {%- assign product = bundle_product -%}
17
+ {% render 'runwell-bundle-data', product: bundle_product %}
18
+ {%- assign component_count = 0 -%}
19
+ {%- if bundle_components and bundle_components.size > 0 -%}
20
+ {%- assign component_count = bundle_components.size -%}
21
+ {%- endif -%}
22
+
23
+ <a
24
+ href="{{ bundle_product.url }}"
25
+ class="runwell-bundle-card runwell-bundle-card--{{ card_variant }}"
26
+ data-bundle-handle="{{ bundle_product.handle }}"
27
+ >
28
+ <div class="runwell-bundle-card__media">
29
+ {%- if bundle_product.featured_image -%}
30
+ {{ bundle_product.featured_image | image_url: width: 600 | image_tag:
31
+ width: 300,
32
+ height: 300,
33
+ loading: 'lazy',
34
+ class: 'runwell-bundle-card__image',
35
+ alt: bundle_product.title,
36
+ sizes: '(min-width: 1024px) 25vw, 50vw'
37
+ }}
38
+ {%- endif -%}
39
+
40
+ {%- if bundle_savings_pct > 0 and card_variant != 'compact' -%}
41
+ <span class="runwell-bundle-card__badge">Save {{ bundle_savings_pct }}%</span>
42
+ {%- endif -%}
43
+ </div>
44
+
45
+ <div class="runwell-bundle-card__body">
46
+ <h3 class="runwell-bundle-card__title">{{ bundle_product.title }}</h3>
47
+
48
+ {%- if component_count > 0 and card_variant != 'compact' -%}
49
+ <p class="runwell-bundle-card__meta">
50
+ {{ component_count }}{% if component_count == 1 %} item{% else %} items{% endif %}
51
+ {%- if bundle_cross_supplier %} &middot; ships in {{ bundle_supplier_count }} packages{%- endif -%}
52
+ </p>
53
+ {%- endif -%}
54
+
55
+ <div class="runwell-bundle-card__price-row">
56
+ <span class="runwell-bundle-card__price">{{ bundle_price | money }}</span>
57
+ {%- if bundle_subtotal > bundle_price -%}
58
+ <span class="runwell-bundle-card__strikethrough">{{ bundle_subtotal | money }}</span>
59
+ {%- endif -%}
60
+ {%- if bundle_savings_pct > 0 and card_variant == 'compact' -%}
61
+ <span class="runwell-bundle-card__badge runwell-bundle-card__badge--inline">Save {{ bundle_savings_pct }}%</span>
62
+ {%- endif -%}
63
+ </div>
64
+
65
+ {%- if card_variant == 'hero' or card_variant == 'default' -%}
66
+ <span class="runwell-bundle-card__cta button button--secondary">View bundle</span>
67
+ {%- endif -%}
68
+ </div>
69
+ </a>
70
+ {%- endif -%}
@@ -0,0 +1,18 @@
1
+ {%- comment -%}
2
+ runwell-bundle-system: runwell-bundle-cross-supplier.liquid.
3
+ Customer-facing disclosure when a bundle ships from multiple suppliers.
4
+ Copy template comes from the theme customizer setting
5
+ bundle_system__cross_supplier_disclosure (default: "This bundle ships
6
+ in {n} packages."), with {n} replaced by bundle_supplier_count.
7
+
8
+ Inputs:
9
+ bundle_supplier_count
10
+ {%- endcomment -%}
11
+
12
+ {%- assign template = settings.bundle_system__cross_supplier_disclosure | default: 'This bundle ships in {n} packages.' -%}
13
+ {%- assign disclosure = template | replace: '{n}', bundle_supplier_count -%}
14
+
15
+ <p class="runwell-bundle-system__cross-supplier" role="note">
16
+ <span class="runwell-bundle-system__cross-supplier-icon" aria-hidden="true">i</span>
17
+ <span class="runwell-bundle-system__cross-supplier-text">{{ disclosure }}</span>
18
+ </p>
@@ -0,0 +1,75 @@
1
+ {%- comment -%}
2
+ runwell-bundle-system: runwell-bundle-data.liquid.
3
+ Single read point for all runwell.bundle_* product metafields. Other
4
+ snippets and sections read the bundle_* Liquid variables this snippet
5
+ emits. Liquid has no first-class object literal so we emit flat vars
6
+ (bundle_mode, bundle_quantity_tiers, etc.) into the parent scope.
7
+
8
+ Usage:
9
+ {% render 'runwell-bundle-data', product: product %}
10
+ {{ bundle_mode }}
11
+ {% for tier in bundle_quantity_tiers %} ... {% endfor %}
12
+
13
+ See _clients/capital-v/lushi/specs/bundle-system/spec.md sections 2.1-2.2.
14
+ {%- endcomment -%}
15
+
16
+ {%- assign mf = product.metafields.runwell -%}
17
+
18
+ {%- assign bundle_mode = mf.bundle_mode.value | default: 'multi_product' -%}
19
+ {%- assign bundle_pricing_model = mf.bundle_pricing_model.value | default: 'fixed_price' -%}
20
+ {%- assign bundle_pricing_value = mf.bundle_pricing_value.value -%}
21
+ {%- assign bundle_components = mf.bundle_components.value -%}
22
+ {%- assign bundle_quantity_tiers = mf.bundle_quantity_tiers.value -%}
23
+ {%- assign bundle_show_in_catalog = mf.bundle_show_in_catalog.value | default: true -%}
24
+ {%- assign bundle_surfaces_enabled = mf.bundle_surfaces_enabled.value -%}
25
+ {%- assign bundle_copy = mf.bundle_copy.value -%}
26
+ {%- assign bundle_free_gift_enabled = mf.bundle_free_gift_enabled.value | default: false -%}
27
+ {%- assign bundle_free_gift_handle = mf.bundle_free_gift_handle.value | default: '' -%}
28
+ {%- assign bundle_fomo_mode = mf.bundle_fomo_mode.value | default: 'none' -%}
29
+ {%- assign bundle_fomo_cycle_days = mf.bundle_fomo_cycle_days.value | default: 30 -%}
30
+ {%- assign bundle_fomo_stock_count = mf.bundle_fomo_stock_count.value | default: 0 -%}
31
+ {%- assign bundle_cross_supplier = mf.bundle_cross_supplier.value | default: false -%}
32
+ {%- assign bundle_supplier_count = mf.bundle_supplier_count.value | default: 1 -%}
33
+ {%- assign bundle_savings_pct_pre = mf.bundle_savings_pct.value -%}
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
+
43
+ {%- comment -%} Compute subtotal for multi_product mode by walking components. {%- endcomment -%}
44
+ {%- assign bundle_subtotal = 0 -%}
45
+ {%- if bundle_components and bundle_components.size > 0 -%}
46
+ {%- for c in bundle_components -%}
47
+ {%- assign comp_product = all_products[c.product_handle] -%}
48
+ {%- assign comp_qty = c.qty | default: 1 -%}
49
+ {%- assign comp_line = comp_product.price | times: comp_qty -%}
50
+ {%- assign bundle_subtotal = bundle_subtotal | plus: comp_line -%}
51
+ {%- endfor -%}
52
+ {%- endif -%}
53
+
54
+ {%- assign bundle_price = product.price -%}
55
+ {%- assign bundle_savings_amount = 0 -%}
56
+ {%- if bundle_subtotal > bundle_price -%}
57
+ {%- assign bundle_savings_amount = bundle_subtotal | minus: bundle_price -%}
58
+ {%- endif -%}
59
+
60
+ {%- assign bundle_savings_pct = 0 -%}
61
+ {%- if bundle_savings_pct_pre -%}
62
+ {%- assign bundle_savings_pct = bundle_savings_pct_pre -%}
63
+ {%- elsif bundle_subtotal > 0 -%}
64
+ {%- assign bundle_savings_pct = bundle_savings_amount | times: 100 | divided_by: bundle_subtotal -%}
65
+ {%- endif -%}
66
+
67
+ {%- comment -%}
68
+ v2 only fields (BS-13). Parsed but unused in v1.
69
+ bundle_subscription_enabled, bundle_subscription_interval,
70
+ bundle_subscription_discount_pct, bundle_subscription_badge_copy.
71
+ {%- endcomment -%}
72
+ {%- assign bundle_subscription_enabled = mf.subscription_enabled.value | default: false -%}
73
+ {%- assign bundle_subscription_interval = mf.subscription_interval.value -%}
74
+ {%- assign bundle_subscription_discount_pct = mf.subscription_discount_pct.value -%}
75
+ {%- assign bundle_subscription_badge_copy = mf.subscription_badge_copy.value | default: '' -%}