@runwell/shopify-toolkit 0.24.4 → 0.25.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/modules/runwell-bundle-system/admin-metafields.json +58 -25
- package/modules/runwell-bundle-system/assets/runwell-bundle-quantity-builder.css +3 -1
- package/modules/runwell-bundle-system/module.json +1 -1
- package/modules/runwell-bundle-system/sections/runwell-bundle-quantity-builder.liquid +60 -19
- package/package.json +1 -1
|
@@ -1,58 +1,91 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$comment": "Source of truth for runwell.bundle_* product metafield
|
|
2
|
+
"$comment": "Source of truth for runwell.bundle_* product metafield + bundle_tier / bundle_component metaobject definitions. v0.25.0 introduces typed metaobjects for Mode A tiers and Mode B components; merchants edit typed fields in admin instead of JSON. The legacy *_v1 JSON keys remain defined for backwards compatibility but are deprecated; new tenants should use the metaobject-backed keys.",
|
|
3
3
|
"spec_ref": "_clients/capital-v/lushi/specs/bundle-system/spec.md#21-per-bundle-product-metafields",
|
|
4
|
+
"shop_metaobjects": [
|
|
5
|
+
{
|
|
6
|
+
"type": "bundle_tier",
|
|
7
|
+
"name": "Bundle tier (Mode A)",
|
|
8
|
+
"description": "One tier in a quantity-tier bundle. Merchants create one entry per row in the bundle UI (e.g. '1x', '2x', '3x'). Linked from a product via the runwell.bundle_quantity_tiers metafield (list reference).",
|
|
9
|
+
"field_definitions": [
|
|
10
|
+
{ "key": "qty", "name": "Quantity", "type": "number_integer", "description": "Number of units of the base product this tier represents." },
|
|
11
|
+
{ "key": "total_price_cents", "name": "Total price (cents)", "type": "number_integer", "description": "Final tier price for the customer, in cents. Example: 3999 = $39.99. Source of truth; no math, no auto-calculation. compareAtPrice is auto-inferred at render time from product.compareAtPrice * qty." },
|
|
12
|
+
{ "key": "label", "name": "Label", "type": "single_line_text_field", "description": "Optional. Falls back to '{qty}x {product.title}' when blank." },
|
|
13
|
+
{ "key": "popular", "name": "Show MOST POPULAR badge", "type": "boolean", "description": "Adds the MOST POPULAR badge to this tier. Does not affect selection." },
|
|
14
|
+
{ "key": "free_shipping", "name": "Show FREE SHIPPING tag", "type": "boolean", "description": "Adds the FREE SHIPPING tag to this tier card." },
|
|
15
|
+
{ "key": "free_gift", "name": "Append free gift on ATC", "type": "boolean", "description": "When selected and added to cart, appends bundle_free_gift_product as an additional cart line." }
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"type": "bundle_component",
|
|
20
|
+
"name": "Bundle component (Mode B)",
|
|
21
|
+
"description": "One product entry in a multi-product bundle. Linked from the bundle product via runwell.bundle_components (list reference).",
|
|
22
|
+
"field_definitions": [
|
|
23
|
+
{ "key": "product", "name": "Product", "type": "product_reference", "description": "The product to include in the bundle." },
|
|
24
|
+
{ "key": "qty", "name": "Quantity", "type": "number_integer", "description": "Number of units of this product included in the bundle." },
|
|
25
|
+
{ "key": "label", "name": "Display label", "type": "single_line_text_field", "description": "Optional override for the product name shown in the bundle UI." }
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"type": "bundle_index",
|
|
30
|
+
"name": "Bundle index",
|
|
31
|
+
"description": "Forward map (bundles -> components) and reverse map (products -> bundles) used by surfaces 2/4 to avoid N+1 reads. Single instance per shop. Rebuilt by runwell-shopify rebuild-bundle-index or by Shopify Flow on bundle product create/update.",
|
|
32
|
+
"field_definitions": [
|
|
33
|
+
{ "key": "entries", "name": "Entries", "type": "json", "description": "{products: {handle: [bundle_handles]}, bundles: {handle: {components: {handle: qty}}}}" }
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
],
|
|
4
37
|
"product_metafields": {
|
|
5
38
|
"namespace": "runwell",
|
|
6
39
|
"owner_type": "PRODUCT",
|
|
7
40
|
"definitions": [
|
|
8
41
|
{ "key": "bundle_mode", "name": "Bundle mode", "type": "single_line_text_field", "validations": [{ "name": "choices", "value": "[\"quantity_tiers\",\"multi_product\",\"mix_match\",\"byob\",\"subscription\"]" }] },
|
|
42
|
+
|
|
43
|
+
{ "key": "bundle_quantity_tiers", "name": "Bundle quantity tiers", "type": "list.metaobject_reference", "validations": [{ "name": "metaobject_definition_type", "value": "bundle_tier" }], "description": "Mode A. List of bundle_tier metaobject entries. Replaces the legacy bundle_quantity_tiers_v1 JSON metafield." },
|
|
44
|
+
{ "key": "bundle_components", "name": "Bundle components", "type": "list.metaobject_reference", "validations": [{ "name": "metaobject_definition_type", "value": "bundle_component" }], "description": "Mode B / mix_match. List of bundle_component metaobject entries. Replaces the legacy bundle_components_v1 JSON metafield." },
|
|
45
|
+
{ "key": "bundle_free_gift_product", "name": "Free gift product", "type": "product_reference", "description": "The product appended at $0 (or its price) when a tier with free_gift=true is selected. Replaces the legacy bundle_free_gift_handle string metafield." },
|
|
46
|
+
|
|
9
47
|
{ "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
|
-
{ "key": "bundle_pricing_value", "name": "Bundle pricing value (JSON)", "type": "json", "description": "Shape varies by pricing_model. See spec.md section 2.2." },
|
|
11
|
-
{ "key": "bundle_components", "name": "Bundle components (JSON)", "type": "json", "description": "Required for multi_product / mix_match. Array of {product_handle, qty}." },
|
|
12
|
-
{ "key": "bundle_quantity_tiers", "name": "Bundle quantity tiers (JSON)", "type": "json", "description": "Required for quantity_tiers. Array of {qty, discount_pct, label?, popular?, free_shipping?, free_gift?}. label is the visible row label (e.g. '2x Sculpting Brush'); falls back to '{qty}x {product.title}' when absent. popular adds the MOST POPULAR badge. free_shipping shows the FREE SHIPPING tag on that tier. free_gift means buying this tier appends the bundle_free_gift_handle product as a $0 line item via ATC." },
|
|
13
48
|
{ "key": "bundle_show_in_catalog", "name": "Show in main catalog", "type": "boolean", "default": true },
|
|
14
|
-
{ "key": "bundle_surfaces_enabled", "name": "Surfaces enabled (JSON)", "type": "json", "description": "Per-bundle surface allowlist. Array of 1..6. Absent = all enabled at tenant level." },
|
|
15
|
-
{ "key": "bundle_copy", "name": "Per-surface copy (JSON)", "type": "json", "description": "Per-surface eyebrow/heading/cta overrides." },
|
|
16
49
|
{ "key": "bundle_free_gift_enabled", "name": "Free gift enabled", "type": "boolean", "default": false },
|
|
17
|
-
{ "key": "bundle_free_gift_handle", "name": "Free gift product handle", "type": "single_line_text_field" },
|
|
18
50
|
{ "key": "bundle_fomo_mode", "name": "FOMO mode", "type": "single_line_text_field", "validations": [{ "name": "choices", "value": "[\"none\",\"discount\",\"scarcity\",\"both\"]" }] },
|
|
19
51
|
{ "key": "bundle_fomo_cycle_days", "name": "FOMO cycle (days)", "type": "number_integer" },
|
|
20
52
|
{ "key": "bundle_fomo_stock_count", "name": "FOMO stock count", "type": "number_integer" },
|
|
21
53
|
{ "key": "bundle_cross_supplier", "name": "Cross-supplier", "type": "boolean", "default": false },
|
|
22
54
|
{ "key": "bundle_supplier_count", "name": "Supplier count", "type": "number_integer" },
|
|
23
|
-
{ "key": "
|
|
24
|
-
{ "key": "
|
|
25
|
-
{ "key": "bundle_rating_score", "name": "Rating score display", "type": "single_line_text_field", "description": "Rating string shown above the title (e.g. '4.8/5'). Display-only; not wired to a review provider for v1." },
|
|
55
|
+
{ "key": "bundle_sale_prefix", "name": "Sale prefix (Mode A)", "type": "single_line_text_field", "description": "Subheading copy displayed under the product title on Mode A quantity-tier surfaces." },
|
|
56
|
+
{ "key": "bundle_rating_score", "name": "Rating score display", "type": "single_line_text_field", "description": "Rating string shown above the title (e.g. '4.8/5'). Display-only." },
|
|
26
57
|
{ "key": "bundle_rating_count", "name": "Rating count display", "type": "single_line_text_field", "description": "Review count string shown next to the rating score (e.g. '2,400+ Reviews')." },
|
|
58
|
+
|
|
59
|
+
{ "key": "bundle_surfaces_enabled", "name": "Surfaces enabled (JSON)", "type": "json", "description": "Per-bundle surface allowlist. Array of 1..6. Absent = all enabled at tenant level." },
|
|
60
|
+
{ "key": "bundle_copy", "name": "Per-surface copy (JSON)", "type": "json", "description": "Per-surface eyebrow/heading/cta overrides." },
|
|
61
|
+
|
|
27
62
|
{ "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 },
|
|
28
63
|
{ "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 },
|
|
29
64
|
{ "key": "bundle_byob_max_picks", "name": "BYOB max picks", "type": "number_integer", "description": "Mode C only. Maximum allowed selections.", "v1_5": true },
|
|
30
65
|
{ "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 },
|
|
31
66
|
{ "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 },
|
|
32
67
|
{ "key": "bundle_byob_required_handles", "name": "BYOB required handles", "type": "list.product_reference", "description": "Mode C only. Pre-selected, not deselectable.", "v1_5": true },
|
|
68
|
+
|
|
69
|
+
{ "key": "bundle_quantity_tiers_v1", "name": "Bundle quantity tiers (legacy JSON)", "type": "json", "description": "DEPRECATED. Pre-v0.25 JSON shape. Liquid falls back to this only if bundle_quantity_tiers (metaobject list) is empty. New tenants should not use this.", "deprecated": true },
|
|
70
|
+
{ "key": "bundle_components_v1", "name": "Bundle components (legacy JSON)", "type": "json", "description": "DEPRECATED. Pre-v0.25 JSON shape.", "deprecated": true },
|
|
71
|
+
{ "key": "bundle_free_gift_handle", "name": "Free gift product handle (legacy)", "type": "single_line_text_field", "description": "DEPRECATED. Pre-v0.25 string handle. Use bundle_free_gift_product (product_reference) instead.", "deprecated": true },
|
|
72
|
+
{ "key": "bundle_pricing_value", "name": "Bundle pricing value (legacy JSON)", "type": "json", "description": "DEPRECATED.", "deprecated": true },
|
|
73
|
+
{ "key": "bundle_savings_pct", "name": "Computed savings percent (legacy)", "type": "number_decimal", "description": "DEPRECATED. No longer computed; compareAtPrice and total_price_cents are now the source of truth.", "deprecated": true },
|
|
74
|
+
|
|
33
75
|
{ "key": "subscription_enabled", "name": "Subscription enabled (v2)", "type": "boolean", "default": false, "v2_only": true },
|
|
34
76
|
{ "key": "subscription_interval", "name": "Subscription interval (JSON, v2)", "type": "json", "v2_only": true },
|
|
35
77
|
{ "key": "subscription_discount_pct", "name": "Subscription discount percent (v2)", "type": "number_decimal", "v2_only": true },
|
|
36
78
|
{ "key": "subscription_badge_copy", "name": "Subscription badge copy (v2)", "type": "single_line_text_field", "v2_only": true }
|
|
37
79
|
]
|
|
38
80
|
},
|
|
39
|
-
"shop_metaobjects": [
|
|
40
|
-
{
|
|
41
|
-
"type": "bundle_index",
|
|
42
|
-
"name": "Bundle index",
|
|
43
|
-
"description": "Forward map (bundles -> components) and reverse map (products -> bundles) used by surfaces 2/4 to avoid N+1 reads. Single instance per shop. Rebuilt by runwell-shopify rebuild-bundle-index or by Shopify Flow on bundle product create/update.",
|
|
44
|
-
"field_definitions": [
|
|
45
|
-
{ "key": "entries", "name": "Entries", "type": "json", "description": "{products: {handle: [bundle_handles]}, bundles: {handle: {components: {handle: qty}}}}" }
|
|
46
|
-
]
|
|
47
|
-
}
|
|
48
|
-
],
|
|
49
81
|
"cross_field_validation": [
|
|
50
|
-
{ "rule": "bundle_quantity_tiers
|
|
51
|
-
{ "rule": "
|
|
82
|
+
{ "rule": "bundle_quantity_tiers list non-empty when bundle_mode == 'quantity_tiers'", "enforced_by": "runwell-shopify qa --bundles" },
|
|
83
|
+
{ "rule": "each bundle_tier entry requires qty and total_price_cents", "enforced_by": "runwell-shopify qa --bundles" },
|
|
84
|
+
{ "rule": "bundle_components list non-empty when bundle_mode in ['multi_product','mix_match']", "enforced_by": "runwell-shopify qa --bundles" },
|
|
85
|
+
{ "rule": "each bundle_component entry requires product and qty", "enforced_by": "runwell-shopify qa --bundles" },
|
|
86
|
+
{ "rule": "bundle_free_gift_product required when any tier has free_gift=true", "enforced_by": "runwell-shopify qa --bundles" },
|
|
52
87
|
{ "rule": "bundle_byob_candidates + bundle_byob_min_picks + bundle_byob_max_picks required when bundle_mode == 'byob'", "enforced_by": "runwell-shopify qa --bundles" },
|
|
53
88
|
{ "rule": "bundle_byob_min_picks <= bundle_byob_max_picks", "enforced_by": "runwell-shopify qa --bundles" },
|
|
54
|
-
{ "rule": "bundle_byob_layout == 'radio' requires bundle_byob_categories", "enforced_by": "runwell-shopify qa --bundles" }
|
|
55
|
-
{ "rule": "bundle_pricing_value shape matches bundle_pricing_model (see spec.md section 2.2)", "enforced_by": "runwell-shopify qa --bundles" },
|
|
56
|
-
{ "rule": "bundle_free_gift_handle required when bundle_free_gift_enabled == true", "enforced_by": "runwell-shopify qa --bundles" }
|
|
89
|
+
{ "rule": "bundle_byob_layout == 'radio' requires bundle_byob_categories", "enforced_by": "runwell-shopify qa --bundles" }
|
|
57
90
|
]
|
|
58
91
|
}
|
|
@@ -182,7 +182,9 @@
|
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
.runwell-bundle-quantity-builder__option--popular {
|
|
185
|
-
|
|
185
|
+
/* Visual emphasis lives in the MOST POPULAR badge; do not stack a
|
|
186
|
+
colored border here, since that would still read as 'selected'
|
|
187
|
+
when the customer has actually picked a different tier. */
|
|
186
188
|
position: relative;
|
|
187
189
|
}
|
|
188
190
|
|
|
@@ -39,17 +39,37 @@
|
|
|
39
39
|
{%- else -%}
|
|
40
40
|
|
|
41
41
|
{%- assign mf = product.metafields.runwell -%}
|
|
42
|
-
|
|
42
|
+
|
|
43
|
+
{%- comment -%}
|
|
44
|
+
v0.25+ reads tiers from a metaobject list. Each entry has typed
|
|
45
|
+
fields (qty, total_price_cents, label, popular, free_shipping,
|
|
46
|
+
free_gift). Falls back to the legacy JSON shape so tenants that
|
|
47
|
+
have not migrated keep working.
|
|
48
|
+
{%- endcomment -%}
|
|
49
|
+
{%- assign tier_objects = mf.bundle_quantity_tiers.value -%}
|
|
50
|
+
{%- assign tiers_legacy = mf.bundle_quantity_tiers_v1.value -%}
|
|
51
|
+
{%- assign tiers_is_metaobject = false -%}
|
|
52
|
+
{%- if tier_objects and tier_objects.first.type contains 'bundle_tier' -%}
|
|
53
|
+
{%- assign tiers_is_metaobject = true -%}
|
|
54
|
+
{%- endif -%}
|
|
55
|
+
|
|
43
56
|
{%- assign sale_prefix = mf.bundle_sale_prefix.value | default: section.settings.sale_prefix -%}
|
|
44
57
|
{%- assign fomo_mode = mf.bundle_fomo_mode.value | default: 'none' -%}
|
|
45
58
|
{%- assign fomo_cycle_days = mf.bundle_fomo_cycle_days.value | default: 30 -%}
|
|
46
59
|
{%- assign fomo_stock_count = mf.bundle_fomo_stock_count.value | default: 0 -%}
|
|
47
60
|
{%- assign rating_score = mf.bundle_rating_score.value -%}
|
|
48
61
|
{%- assign rating_count = mf.bundle_rating_count.value -%}
|
|
49
|
-
|
|
50
|
-
{%-
|
|
51
|
-
|
|
52
|
-
|
|
62
|
+
|
|
63
|
+
{%- comment -%}
|
|
64
|
+
Free gift: prefer typed product_reference (bundle_free_gift_product).
|
|
65
|
+
Fall back to the legacy string-handle metafield for un-migrated tenants.
|
|
66
|
+
{%- endcomment -%}
|
|
67
|
+
{%- assign free_gift_product = mf.bundle_free_gift_product.value -%}
|
|
68
|
+
{%- if free_gift_product == blank -%}
|
|
69
|
+
{%- assign free_gift_handle = mf.bundle_free_gift_handle.value -%}
|
|
70
|
+
{%- if free_gift_handle != blank -%}
|
|
71
|
+
{%- assign free_gift_product = all_products[free_gift_handle] -%}
|
|
72
|
+
{%- endif -%}
|
|
53
73
|
{%- endif -%}
|
|
54
74
|
|
|
55
75
|
<section
|
|
@@ -164,6 +184,18 @@
|
|
|
164
184
|
{%- endif -%}
|
|
165
185
|
{%- endif -%}
|
|
166
186
|
|
|
187
|
+
{%- comment -%}
|
|
188
|
+
Normalize iteration source. tier_objects is either a list of
|
|
189
|
+
metaobjects (v0.25+) or empty. tiers_legacy is the legacy
|
|
190
|
+
JSON array. We render whichever is present; metaobject wins
|
|
191
|
+
when both exist.
|
|
192
|
+
{%- endcomment -%}
|
|
193
|
+
{%- if tiers_is_metaobject -%}
|
|
194
|
+
{%- assign tiers = tier_objects -%}
|
|
195
|
+
{%- else -%}
|
|
196
|
+
{%- assign tiers = tiers_legacy -%}
|
|
197
|
+
{%- endif -%}
|
|
198
|
+
|
|
167
199
|
{%- if tiers and tiers.size > 0 -%}
|
|
168
200
|
{%- assign has_popular = false -%}
|
|
169
201
|
{%- for t in tiers -%}
|
|
@@ -171,13 +203,14 @@
|
|
|
171
203
|
{%- endfor -%}
|
|
172
204
|
|
|
173
205
|
{%- comment -%}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
206
|
+
Compare price is always inferred from the product's MSRP:
|
|
207
|
+
compare_at_price (when set) * qty
|
|
208
|
+
falling back to product.price * qty when compareAtPrice is
|
|
209
|
+
unset. This stays inference, never a stored value.
|
|
177
210
|
{%- endcomment -%}
|
|
178
|
-
{%- assign
|
|
179
|
-
{%- if
|
|
180
|
-
{%- assign
|
|
211
|
+
{%- assign compare_base = product.selected_or_first_available_variant.compare_at_price | default: product.price -%}
|
|
212
|
+
{%- if compare_base == 0 or compare_base == blank -%}
|
|
213
|
+
{%- assign compare_base = product.price -%}
|
|
181
214
|
{%- endif -%}
|
|
182
215
|
|
|
183
216
|
<form
|
|
@@ -207,15 +240,23 @@
|
|
|
207
240
|
{%- endif -%}
|
|
208
241
|
|
|
209
242
|
{%- comment -%}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
243
|
+
Pricing source of truth:
|
|
244
|
+
- v0.25+ metaobject path: total_price_cents is stored
|
|
245
|
+
explicitly per tier. No math, no auto-calc.
|
|
246
|
+
- Legacy JSON path: compute from discount_pct against
|
|
247
|
+
compare_base using the direct (100 - pct) formula
|
|
248
|
+
that avoids half-cent rounding.
|
|
249
|
+
compare price stays inferred (compare_base * qty)
|
|
250
|
+
in both paths.
|
|
214
251
|
{%- endcomment -%}
|
|
215
|
-
{%-
|
|
216
|
-
|
|
217
|
-
{%-
|
|
218
|
-
|
|
252
|
+
{%- if t.total_price_cents and t.total_price_cents != 0 -%}
|
|
253
|
+
{%- assign total_price = t.total_price_cents -%}
|
|
254
|
+
{%- else -%}
|
|
255
|
+
{%- assign keep_pct = 100 | minus: t.discount_pct -%}
|
|
256
|
+
{%- assign per_unit_price = compare_base | times: keep_pct | divided_by: 100 -%}
|
|
257
|
+
{%- assign total_price = per_unit_price | times: t.qty -%}
|
|
258
|
+
{%- endif -%}
|
|
259
|
+
{%- assign full_price = compare_base | times: t.qty -%}
|
|
219
260
|
{%- assign savings = full_price | minus: total_price -%}
|
|
220
261
|
|
|
221
262
|
<label
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runwell/shopify-toolkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.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",
|