@lime-bundles/widget 4.1.1 → 4.3.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/dist/index.js CHANGED
@@ -5,6 +5,8 @@ import {
5
5
  BUNDLES_FOR_PRODUCT_QUERY,
6
6
  CART_CREATE_MUTATION,
7
7
  CART_LINES_ADD_MUTATION,
8
+ CART_LINES_QUERY,
9
+ CART_LINES_REMOVE_MUTATION,
8
10
  SHOP_SETTINGS_QUERY,
9
11
  parseMetaobjectBundle,
10
12
  observeImpression,
@@ -15,17 +17,38 @@ import {
15
17
  hasInContext,
16
18
  withInContext,
17
19
  isVisibleInMarket,
18
- parseOutOfStockBehavior,
19
- DEFAULT_OUT_OF_STOCK_BEHAVIOR,
20
20
  getVisitorId,
21
21
  resolveAbTests
22
22
  } from "@lime-bundles/core";
23
23
 
24
+ // ../render/src/host.ts
25
+ function intlFormatMoney(currencyCode) {
26
+ return (cents) => {
27
+ try {
28
+ return new Intl.NumberFormat(void 0, {
29
+ style: "currency",
30
+ currency: currencyCode
31
+ }).format(cents / 100);
32
+ } catch {
33
+ return currencyCode + " " + (cents / 100).toFixed(2);
34
+ }
35
+ };
36
+ }
37
+ var BUNDLE_GID_ATTRIBUTE = "_lime_bundle_gid";
38
+ var BUNDLE_TYPE_ATTRIBUTE = "_lime_bundle_type";
39
+ function bundleLineAttributes(bundleGid, bundleType) {
40
+ return [
41
+ { key: BUNDLE_GID_ATTRIBUTE, value: bundleGid },
42
+ { key: BUNDLE_TYPE_ATTRIBUTE, value: bundleType }
43
+ ];
44
+ }
45
+
24
46
  // ../render/src/adapt.ts
25
47
  import {
26
48
  formatUnitPrice,
27
49
  isVariantFulfillable,
28
- resolveBundleQty
50
+ resolveBundleQty,
51
+ stockCapForVariant
29
52
  } from "@lime-bundles/core";
30
53
  function numericId(gid) {
31
54
  const tail = gid.slice(gid.lastIndexOf("/") + 1);
@@ -49,7 +72,10 @@ function adaptVariant(v, requiredQty, fallbackCurrency) {
49
72
  price: toCents(v.price?.amount),
50
73
  compareAtPrice: toCents(v.compareAtPrice?.amount),
51
74
  unitPrice: formatUnitPrice(v.unitPrice, v.unitPriceMeasurement, fallbackCurrency),
52
- image: v.image?.url ?? null
75
+ image: v.image?.url ?? null,
76
+ // Carried so the picker's stepper cap and cross-slot stock guard can
77
+ // bind on the headless surface; dropping it here left them capless.
78
+ inventoryQuantity: stockCapForVariant(v)
53
79
  };
54
80
  }
55
81
  function adaptProduct(product, bundleId, opts) {
@@ -104,6 +130,14 @@ function findVariant(product, variantId) {
104
130
  }
105
131
  return product.variants[0];
106
132
  }
133
+ function resolveSellableVariant(product) {
134
+ const seeded = findVariant(product, product.selectedVariantId);
135
+ if (seeded && seeded.available !== false) return seeded;
136
+ for (let i = 0; i < product.variants.length; i++) {
137
+ if (product.variants[i].available !== false) return product.variants[i];
138
+ }
139
+ return null;
140
+ }
107
141
  function findVariantByOptions(product, optionValues) {
108
142
  if (!product.variants || !optionValues) return null;
109
143
  for (let i = 0; i < product.variants.length; i++) {
@@ -216,28 +250,6 @@ function recalcPricing(container, products, formatMoney) {
216
250
  }
217
251
  }
218
252
 
219
- // ../render/src/host.ts
220
- function intlFormatMoney(currencyCode) {
221
- return (cents) => {
222
- try {
223
- return new Intl.NumberFormat(void 0, {
224
- style: "currency",
225
- currency: currencyCode
226
- }).format(cents / 100);
227
- } catch {
228
- return currencyCode + " " + (cents / 100).toFixed(2);
229
- }
230
- };
231
- }
232
- var BUNDLE_GID_ATTRIBUTE = "_lime_bundle_gid";
233
- var BUNDLE_TYPE_ATTRIBUTE = "_lime_bundle_type";
234
- function bundleLineAttributes(bundleGid, bundleType) {
235
- return [
236
- { key: BUNDLE_GID_ATTRIBUTE, value: bundleGid },
237
- { key: BUNDLE_TYPE_ATTRIBUTE, value: bundleType }
238
- ];
239
- }
240
-
241
253
  // ../render/src/product/option-selects.ts
242
254
  function bindVariantSelects(row, getProduct, onSelected) {
243
255
  const optionSelects = row.querySelectorAll("[data-variant-option]");
@@ -1174,12 +1186,11 @@ function bindDropdowns(root) {
1174
1186
  }
1175
1187
 
1176
1188
  // src/renderers/fixed.ts
1177
- function renderFixedBundle(container, bundle, outOfStockBehavior, onAddToCart, onCleanup) {
1189
+ function renderFixedBundle(container, bundle, onAddToCart, onCleanup) {
1178
1190
  const wc = bundle.widgetConfig;
1179
1191
  const products = adaptProducts(bundle);
1180
1192
  if (!products.length) return;
1181
1193
  const oosProducts = products.filter((p) => !p.variants.some((v) => v.available));
1182
- if (outOfStockBehavior === "hide" && oosProducts.length > 0) return;
1183
1194
  const currency = bundle.products[0]?.priceRange.minVariantPrice.currencyCode ?? "USD";
1184
1195
  const formatMoney = intlFormatMoney(currency);
1185
1196
  const t = DEFAULT_STRINGS;
@@ -1204,7 +1215,9 @@ function renderFixedBundle(container, bundle, outOfStockBehavior, onAddToCart, o
1204
1215
  });
1205
1216
  shell.products.appendChild(row);
1206
1217
  if (isOos) continue;
1207
- const selected = findVariant(product, product.selectedVariantId);
1218
+ const sellable = resolveSellableVariant(product);
1219
+ if (sellable) product.selectedVariantId = sellable.id;
1220
+ const selected = sellable ?? findVariant(product, product.selectedVariantId);
1208
1221
  hydrateRowControls(row, product, selected);
1209
1222
  applyVariant(row, selected, product, formatMoney);
1210
1223
  bindVariantSelects(
@@ -1799,6 +1812,17 @@ function unlock() {
1799
1812
  window.scrollTo(0, savedY);
1800
1813
  }
1801
1814
 
1815
+ // ../render/src/picker/sort.ts
1816
+ function sinkAddedRows(list, rowEls, isAdded) {
1817
+ const front = [];
1818
+ const back = [];
1819
+ for (let i = 0; i < rowEls.length; i++) {
1820
+ (isAdded(i) ? back : front).push(rowEls[i]);
1821
+ }
1822
+ for (const el2 of front) list.appendChild(el2);
1823
+ for (const el2 of back) list.appendChild(el2);
1824
+ }
1825
+
1802
1826
  // ../render/src/mix-match/modal.ts
1803
1827
  var ALL_TYPES = "__all__";
1804
1828
  var CLOSE_TRANSITION_MS = 300;
@@ -2023,12 +2047,22 @@ function createPickerModal(deps) {
2023
2047
  syncActiveFilterPill();
2024
2048
  if (searchInput) searchInput.value = "";
2025
2049
  filterProducts(searchInput ? searchInput.value : "");
2050
+ if (modalList) {
2051
+ const selectedProductIds = /* @__PURE__ */ Object.create(null);
2052
+ for (const it of items) selectedProductIds[String(it.productId)] = true;
2053
+ sinkAddedRows(
2054
+ modalList,
2055
+ rows.map((r) => r.el),
2056
+ (i) => selectedProductIds[String(eligibleProducts[i].id)] === true
2057
+ );
2058
+ modalList.scrollTop = 0;
2059
+ }
2026
2060
  overlay.style.display = "";
2027
2061
  void overlay.offsetHeight;
2028
2062
  overlay.classList.add("lb-mix-match__modal-overlay--open");
2029
2063
  lock();
2030
2064
  setTimeout(() => {
2031
- if (searchInput) searchInput.focus();
2065
+ if (closeBtn) closeBtn.focus();
2032
2066
  else modalDialog?.focus();
2033
2067
  }, 50);
2034
2068
  }
@@ -2585,7 +2619,7 @@ function buildPickerModal(opts) {
2585
2619
  }
2586
2620
 
2587
2621
  // src/renderers/mix-match.ts
2588
- function renderMixMatchBundle(container, bundle, outOfStockBehavior, onAddToCart, onCleanup, currentProductHandle) {
2622
+ function renderMixMatchBundle(container, bundle, onAddToCart, onCleanup, currentProductHandle) {
2589
2623
  const wc = bundle.widgetConfig;
2590
2624
  const t = DEFAULT_STRINGS;
2591
2625
  const requiredQty = bundle.minQuantity || 1;
@@ -2608,12 +2642,12 @@ function renderMixMatchBundle(container, bundle, outOfStockBehavior, onAddToCart
2608
2642
  compareAtPrice: v.compareAtPrice,
2609
2643
  unitPrice: v.unitPrice,
2610
2644
  image: v.image,
2611
- inventoryQuantity: null
2645
+ inventoryQuantity: v.inventoryQuantity ?? null
2612
2646
  }))
2613
2647
  }));
2614
2648
  if (!eligibleProducts.length) return;
2615
2649
  const poolUnits = eligibleProducts.filter((p) => p.available).length;
2616
- if (outOfStockBehavior === "hide" && poolUnits === 0) return;
2650
+ if (poolUnits === 0) return;
2617
2651
  const formatMoney = intlFormatMoney(
2618
2652
  bundle.products[0]?.priceRange.minVariantPrice.currencyCode ?? "USD"
2619
2653
  );
@@ -2776,10 +2810,51 @@ function renderMixMatchBundle(container, bundle, outOfStockBehavior, onAddToCart
2776
2810
  // src/renderers/volume.ts
2777
2811
  import {
2778
2812
  bestValueTierIndex,
2813
+ isVariantFulfillable as isVariantFulfillable2,
2814
+ resolveContextProduct,
2779
2815
  resolveDefaultTierIndex,
2780
- resolvePopularTierIndex
2816
+ resolvePopularTierIndex,
2817
+ stockCapForVariant as stockCapForVariant2
2781
2818
  } from "@lime-bundles/core";
2782
2819
 
2820
+ // ../render/src/volume/stock.ts
2821
+ var TIER_OOS_CLASS = "lb-volume__tier--oos";
2822
+ function tierQty(el2) {
2823
+ return parseInt(el2.getAttribute("data-tier-qty") ?? "", 10) || 0;
2824
+ }
2825
+ function tierUnavailable(el2) {
2826
+ return el2.getAttribute("aria-disabled") === "true";
2827
+ }
2828
+ function applyTierStockState(tierEls, cap) {
2829
+ for (let i = 0; i < tierEls.length; i++) {
2830
+ const el2 = tierEls[i];
2831
+ const unavailable = cap !== null && tierQty(el2) > cap;
2832
+ if (unavailable) {
2833
+ el2.classList.add(TIER_OOS_CLASS);
2834
+ el2.setAttribute("aria-disabled", "true");
2835
+ } else {
2836
+ el2.classList.remove(TIER_OOS_CLASS);
2837
+ el2.removeAttribute("aria-disabled");
2838
+ }
2839
+ }
2840
+ }
2841
+ function resolveAvailableTierIndex(tierEls, preferred) {
2842
+ const p = tierEls[preferred];
2843
+ if (p && !tierUnavailable(p)) return preferred;
2844
+ for (let i = 0; i < tierEls.length; i++) {
2845
+ if (!tierUnavailable(tierEls[i])) return i;
2846
+ }
2847
+ return -1;
2848
+ }
2849
+ function stepToAvailableTier(tierEls, from, direction) {
2850
+ let i = from + direction;
2851
+ while (i >= 0 && i < tierEls.length) {
2852
+ if (!tierUnavailable(tierEls[i])) return i;
2853
+ i += direction;
2854
+ }
2855
+ return from;
2856
+ }
2857
+
2783
2858
  // ../render/src/volume/pricing.ts
2784
2859
  function calcTierPrice(basePrice, discountType, tierEl) {
2785
2860
  if (discountType === "fixed_amount") {
@@ -2869,15 +2944,16 @@ function selectTier(container, allTiers, basePrice, discountType, index, transla
2869
2944
  }
2870
2945
 
2871
2946
  // src/renderers/volume.ts
2872
- function renderVolumeBundle(container, bundle, outOfStockBehavior, onAddToCart) {
2947
+ function renderVolumeBundle(container, bundle, onAddToCart, productContext) {
2873
2948
  const wc = bundle.widgetConfig;
2874
- const product = bundle.products[0];
2949
+ const product = resolveContextProduct(bundle.products, productContext);
2875
2950
  if (!product) return;
2876
2951
  const variants = product.variants.nodes;
2877
- const pricingVariant = variants.find((v) => v.availableForSale) ?? variants[0];
2952
+ const minTierQty = Math.min(
2953
+ ...bundle.volumeTiers.map((tier) => tier.minQuantity)
2954
+ );
2955
+ const pricingVariant = variants.find((v) => isVariantFulfillable2(v, minTierQty)) ?? variants.find((v) => v.availableForSale) ?? variants[0];
2878
2956
  if (!pricingVariant) return;
2879
- const anyAvailable = variants.some((v) => v.availableForSale);
2880
- if (outOfStockBehavior === "hide" && !anyAvailable) return;
2881
2957
  const basePrice = toCents(pricingVariant.price.amount);
2882
2958
  const discountType = bundle.discountConfig.discountType;
2883
2959
  const formatMoney = intlFormatMoney(product.priceRange.minVariantPrice.currencyCode);
@@ -2928,28 +3004,31 @@ function renderVolumeBundle(container, bundle, outOfStockBehavior, onAddToCart)
2928
3004
  shell.products.replaceWith(group);
2929
3005
  const tierEls = group.querySelectorAll("[data-tier-index]");
2930
3006
  updateAllTierPrices(tierEls, basePrice, discountType, formatMoney);
2931
- let selectedIndex = defaultIdx;
3007
+ applyTierStockState(tierEls, stockCapForVariant2(pricingVariant));
3008
+ let selectedIndex = resolveAvailableTierIndex(tierEls, defaultIdx);
3009
+ const noTierAvailable = selectedIndex === -1;
3010
+ if (noTierAvailable) selectedIndex = defaultIdx;
2932
3011
  const apply = () => selectTier(shell.root, tierEls, basePrice, discountType, selectedIndex, t, formatMoney);
2933
3012
  group.addEventListener("click", (e) => {
2934
3013
  const el2 = e.target.closest("[data-tier-index]");
2935
- if (!el2) return;
3014
+ if (!el2 || tierUnavailable(el2)) return;
2936
3015
  selectedIndex = parseInt(el2.getAttribute("data-tier-index") ?? "", 10) || 0;
2937
3016
  apply();
2938
3017
  });
2939
3018
  group.addEventListener("keydown", (e) => {
2940
3019
  if (e.key === "ArrowDown" || e.key === "ArrowRight") {
2941
3020
  e.preventDefault();
2942
- selectedIndex = Math.min(selectedIndex + 1, tierEls.length - 1);
3021
+ selectedIndex = stepToAvailableTier(tierEls, selectedIndex, 1);
2943
3022
  apply();
2944
3023
  tierEls[selectedIndex].focus();
2945
3024
  } else if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
2946
3025
  e.preventDefault();
2947
- selectedIndex = Math.max(selectedIndex - 1, 0);
3026
+ selectedIndex = stepToAvailableTier(tierEls, selectedIndex, -1);
2948
3027
  apply();
2949
3028
  tierEls[selectedIndex].focus();
2950
3029
  }
2951
3030
  });
2952
- if (!anyAvailable) {
3031
+ if (noTierAvailable) {
2953
3032
  shell.cta.disabled = true;
2954
3033
  const label = shell.cta.querySelector("[data-cta-label]");
2955
3034
  if (label) label.textContent = t.outOfStock || "Out of stock";
@@ -3084,7 +3163,7 @@ function buildBogoPlus() {
3084
3163
  plus.innerHTML = '<svg width="12" height="12" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><line x1="9" y1="3" x2="9" y2="15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><line x1="3" y1="9" x2="15" y2="9" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
3085
3164
  return plus;
3086
3165
  }
3087
- function renderBogoBundle(container, bundle, outOfStockBehavior, onAddToCart, onCleanup) {
3166
+ function renderBogoBundle(container, bundle, onAddToCart, onCleanup) {
3088
3167
  const wc = bundle.widgetConfig;
3089
3168
  const buyProduct = bundle.products.find((p) => p.id === bundle.buyProductId);
3090
3169
  const getProduct = bundle.products.find((p) => p.id === bundle.getProductId);
@@ -3113,7 +3192,6 @@ function renderBogoBundle(container, bundle, outOfStockBehavior, onAddToCart, on
3113
3192
  }
3114
3193
  ];
3115
3194
  const oosCount = sides.filter((s) => !s.variants.some((v) => v.available)).length;
3116
- if (outOfStockBehavior === "hide" && oosCount > 0) return;
3117
3195
  const formatMoney = intlFormatMoney(
3118
3196
  buyProduct.priceRange.minVariantPrice.currencyCode
3119
3197
  );
@@ -3549,6 +3627,14 @@ function createWizard(deps) {
3549
3627
  rows.push(row);
3550
3628
  modalList.appendChild(row.el);
3551
3629
  }
3630
+ const stepPicks = selections[forStep] || [];
3631
+ const selectedProductIds = /* @__PURE__ */ Object.create(null);
3632
+ for (const it of stepPicks) selectedProductIds[String(it.productId)] = true;
3633
+ sinkAddedRows(
3634
+ modalList,
3635
+ rows.map((r) => r.el),
3636
+ (i) => selectedProductIds[String(pool[i].id)] === true
3637
+ );
3552
3638
  deps.dropdown?.bindAll(modalList);
3553
3639
  }
3554
3640
  function onListClick(e) {
@@ -3711,6 +3797,7 @@ function createWizard(deps) {
3711
3797
  syncActiveFilterPill();
3712
3798
  if (searchInput) searchInput.value = "";
3713
3799
  filterProducts("");
3800
+ if (modalList) modalList.scrollTop = 0;
3714
3801
  buildStepSegments(stepIndex);
3715
3802
  refresh();
3716
3803
  if (modalLive) {
@@ -3734,7 +3821,7 @@ function createWizard(deps) {
3734
3821
  overlay.classList.add("lb-mix-match__modal-overlay--open");
3735
3822
  lock();
3736
3823
  setTimeout(() => {
3737
- if (searchInput) searchInput.focus();
3824
+ if (closeBtn) closeBtn.focus();
3738
3825
  else modalDialog?.focus();
3739
3826
  }, 50);
3740
3827
  }
@@ -3802,7 +3889,7 @@ function createWizard(deps) {
3802
3889
  }
3803
3890
 
3804
3891
  // src/renderers/multi-step.ts
3805
- function renderMultiStepBundle(container, bundle, outOfStockBehavior, onAddToCart, onCleanup) {
3892
+ function renderMultiStepBundle(container, bundle, onAddToCart, onCleanup) {
3806
3893
  const wc = bundle.widgetConfig;
3807
3894
  const t = DEFAULT_STRINGS;
3808
3895
  const rulesMap = mergeRuleMaps(bundle.productRules, bundle.variantRules);
@@ -3835,13 +3922,13 @@ function renderMultiStepBundle(container, bundle, outOfStockBehavior, onAddToCar
3835
3922
  compareAtPrice: v.compareAtPrice,
3836
3923
  unitPrice: v.unitPrice,
3837
3924
  image: v.image,
3838
- inventoryQuantity: null
3925
+ inventoryQuantity: v.inventoryQuantity ?? null
3839
3926
  }))
3840
3927
  };
3841
3928
  })
3842
3929
  );
3843
- const anyPickable = pools.some((pool) => pool.some((p) => p.available));
3844
- if (outOfStockBehavior === "hide" && !anyPickable) return;
3930
+ const everyStepPickable = pools.length > 0 && pools.every((pool) => pool.some((p) => p.available));
3931
+ if (!everyStepPickable) return;
3845
3932
  const formatMoney = intlFormatMoney(
3846
3933
  bundle.products[0]?.priceRange.minVariantPrice.currencyCode ?? "USD"
3847
3934
  );
@@ -5653,7 +5740,7 @@ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
5653
5740
  transition: opacity 0.25s ease, background 0.25s ease, color 0.25s ease, border-color 0.25s ease;
5654
5741
  }
5655
5742
 
5656
- .lb-mix-match__modal-add:hover {
5743
+ .lb-mix-match__modal-add:hover:not(:disabled) {
5657
5744
  opacity: 0.9;
5658
5745
  }
5659
5746
 
@@ -5768,8 +5855,15 @@ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
5768
5855
  color: color-mix(in srgb, var(--lb-picker-text) 70%, transparent);
5769
5856
  }
5770
5857
 
5771
- /* "Done" reuses the picker Add-button styling so it matches the Add buttons. */
5858
+ /* "Done" reuses the picker Add-button styling so it matches the Add buttons.
5859
+ The flex centring + min-height pin the footer-button silhouette: the wizard
5860
+ Back (bundle-multi-step.css) copies these metrics so the pair stays
5861
+ equal-height whatever border widths the merchant's picker tokens set. */
5772
5862
  .lb-mix-match__modal-done {
5863
+ display: inline-flex;
5864
+ align-items: center;
5865
+ justify-content: center;
5866
+ min-height: 40px;
5773
5867
  padding: 10px 24px;
5774
5868
  transition: opacity 0.25s ease;
5775
5869
  background: var(--lb-picker-add-bg);
@@ -5783,7 +5877,7 @@ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
5783
5877
  cursor: pointer;
5784
5878
  }
5785
5879
 
5786
- .lb-mix-match__modal-done:hover {
5880
+ .lb-mix-match__modal-done:hover:not(:disabled) {
5787
5881
  opacity: 0.9;
5788
5882
  }
5789
5883
 
@@ -5793,6 +5887,16 @@ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
5793
5887
  box-shadow: none;
5794
5888
  }
5795
5889
 
5890
+ /* Disabled Next/Done (wizard step minimum not met) \u2014 same washed-out
5891
+ treatment as a disabled row Add, so an unfinished step reads at a
5892
+ glance. Hover is gated off above; the solid button returns the moment
5893
+ the requirement is met. */
5894
+ .lb-mix-match__modal-done:disabled {
5895
+ background: color-mix(in srgb, var(--lb-picker-add-bg) 35%, var(--lb-picker-bg));
5896
+ color: color-mix(in srgb, var(--lb-picker-add-label) 85%, transparent);
5897
+ cursor: not-allowed;
5898
+ }
5899
+
5796
5900
  /* Hidden utility for search filtering */
5797
5901
  .lb-hidden {
5798
5902
  display: none !important;
@@ -6049,6 +6153,13 @@ var BUNDLE_VOLUME_CSS = `/* Lime Bundles \u2014 Volume / Quantity Breaks styles
6049
6153
  white-space: nowrap;
6050
6154
  font-variant-numeric: tabular-nums;
6051
6155
  }
6156
+
6157
+ /* Tier the selected variant can't cover \u2014 same greyed treatment as an
6158
+ out-of-stock product row, and not selectable. */
6159
+ .lb-volume__tier--oos {
6160
+ opacity: 0.5;
6161
+ cursor: not-allowed;
6162
+ }
6052
6163
  `;
6053
6164
  var BUNDLE_BOGO_CSS = `/* Lime Bundles \u2014 BOGO (Buy X Get Y) bundle styles */
6054
6165
 
@@ -6217,14 +6328,23 @@ var BUNDLE_MULTI_STEP_CSS = `/**
6217
6328
  justify-self: end;
6218
6329
  }
6219
6330
 
6220
- /* Back \u2014 secondary treatment beside the primary Next/Done (which reuse
6221
- .lb-mix-match__modal-done). */
6331
+ /* Back \u2014 secondary (outlined) treatment beside the primary Next/Done
6332
+ (which reuse .lb-mix-match__modal-done). Same silhouette as Next \u2014
6333
+ metrics copied from the modal-done recipe (padding, type, radius,
6334
+ min-height) \u2014 and picker tokens only: the modal is portaled out of the
6335
+ widget, so the widget vars (--lb-text, --lb-border-*) it previously
6336
+ used don't reliably resolve here and break palette isolation. */
6222
6337
  .lb-multi-step__modal-back {
6223
- padding: 10px 16px;
6338
+ display: inline-flex;
6339
+ align-items: center;
6340
+ justify-content: center;
6341
+ min-height: 40px;
6342
+ padding: 10px 24px;
6224
6343
  background: none;
6225
- border: var(--lb-border-width) solid var(--lb-border-color);
6226
- border-radius: var(--lb-radius-sm);
6227
- color: var(--lb-text);
6344
+ border: var(--lb-picker-border-width) solid var(--lb-picker-border-color);
6345
+ border-radius: var(--lb-picker-radius-sm);
6346
+ box-sizing: border-box;
6347
+ color: var(--lb-picker-text);
6228
6348
  font-family: inherit;
6229
6349
  font-size: 14px;
6230
6350
  font-weight: 600;
@@ -6233,7 +6353,7 @@ var BUNDLE_MULTI_STEP_CSS = `/**
6233
6353
  }
6234
6354
 
6235
6355
  .lb-multi-step__modal-back:hover {
6236
- background: color-mix(in srgb, var(--lb-text) 5%, transparent);
6356
+ background: color-mix(in srgb, var(--lb-picker-text) 5%, transparent);
6237
6357
  }
6238
6358
 
6239
6359
  .lb-multi-step__modal-back:focus-visible {
@@ -6625,6 +6745,7 @@ var LimeBundleElement = class extends HTMLElement {
6625
6745
  "storefront-token",
6626
6746
  "bundle-gid",
6627
6747
  "product-handle",
6748
+ "product-id",
6628
6749
  "app-url",
6629
6750
  "analytics",
6630
6751
  "locale",
@@ -6663,12 +6784,6 @@ var LimeBundleElement = class extends HTMLElement {
6663
6784
  * `.lb-bundle-widget { ... }` reach the widget's DOM.
6664
6785
  */
6665
6786
  shopCustomCss = null;
6666
- /**
6667
- * Shop-wide out-of-stock behaviour, resolved from the `$app` shop metafield
6668
- * before the first render. Defaults to `show_greyed_out` so a failed or
6669
- * missing fetch degrades to "bundles still render".
6670
- */
6671
- outOfStockBehavior = DEFAULT_OUT_OF_STOCK_BEHAVIOR;
6672
6787
  constructor() {
6673
6788
  super();
6674
6789
  this.shadow = this.attachShadow({ mode: "open" });
@@ -6691,7 +6806,7 @@ var LimeBundleElement = class extends HTMLElement {
6691
6806
  }
6692
6807
  attributeChangedCallback(name, oldValue, newValue) {
6693
6808
  if (oldValue === newValue || !this.isConnected) return;
6694
- if (name === "bundle-gid" || name === "product-handle" || name === "shop-domain" || name === "storefront-token") {
6809
+ if (name === "bundle-gid" || name === "product-handle" || name === "product-id" || name === "shop-domain" || name === "storefront-token") {
6695
6810
  if (this.shopDomain && this.storefrontToken) {
6696
6811
  this.fetchBundle();
6697
6812
  }
@@ -6709,6 +6824,16 @@ var LimeBundleElement = class extends HTMLElement {
6709
6824
  get productHandleAttr() {
6710
6825
  return this.getAttribute("product-handle") ?? "";
6711
6826
  }
6827
+ /**
6828
+ * The current product's id — full GID (`gid://shopify/Product/123`) or
6829
+ * bare numeric id. Tells multi-product renderers (volume) which of the
6830
+ * bundle's products this embed is standing on; takes precedence over the
6831
+ * handle cascade. Without it (and with no resolvable handle) they bind to
6832
+ * the bundle's first product.
6833
+ */
6834
+ get productIdAttr() {
6835
+ return this.getAttribute("product-id") ?? "";
6836
+ }
6712
6837
  get appUrl() {
6713
6838
  return this.getAttribute("app-url") ?? "";
6714
6839
  }
@@ -6804,9 +6929,6 @@ var LimeBundleElement = class extends HTMLElement {
6804
6929
  const sanitized = sanitizeCustomCss(customCss);
6805
6930
  if (sanitized.ok) this.shopCustomCss = sanitized.css;
6806
6931
  }
6807
- this.outOfStockBehavior = parseOutOfStockBehavior(
6808
- shopSettings?.shop?.outOfStockBehavior?.value
6809
- );
6810
6932
  this.renderBundles();
6811
6933
  } catch (err) {
6812
6934
  if (controller.signal.aborted) return;
@@ -6885,17 +7007,42 @@ var LimeBundleElement = class extends HTMLElement {
6885
7007
  });
6886
7008
  const storage = window.localStorage;
6887
7009
  const key = cartStorageKey(this.shopDomain);
6888
- const existingCartId = storage?.getItem(key) ?? null;
7010
+ let cartId = storage?.getItem(key) ?? null;
7011
+ const isStaleCartError = (errors) => errors.length > 0 && errors.every((e) => (e.field ?? []).includes("cartId"));
7012
+ const stockCapped = (warnings) => (warnings ?? []).some((w) => w.code === "MERCHANDISE_NOT_ENOUGH_STOCK");
7013
+ const fail = (message) => {
7014
+ this.dispatchEvent(
7015
+ new CustomEvent("lime-bundle:error", {
7016
+ detail: { message, code: "CART_ERROR" },
7017
+ bubbles: true,
7018
+ composed: true
7019
+ })
7020
+ );
7021
+ };
6889
7022
  try {
6890
7023
  let checkoutUrl = null;
6891
- if (existingCartId) {
7024
+ if (cartId) {
7025
+ cartId = await this.clearAbandonedBundleLines(
7026
+ client,
7027
+ cartId,
7028
+ () => storage?.removeItem(key)
7029
+ );
7030
+ }
7031
+ if (cartId) {
6892
7032
  const res = await client.query(
6893
7033
  CART_LINES_ADD_MUTATION,
6894
- { cartId: existingCartId, lines }
7034
+ { cartId, lines }
6895
7035
  );
6896
7036
  const payload = res.cartLinesAdd;
6897
7037
  if (payload?.userErrors?.length) {
7038
+ if (!isStaleCartError(payload.userErrors)) {
7039
+ fail(payload.userErrors[0].message);
7040
+ return;
7041
+ }
6898
7042
  storage?.removeItem(key);
7043
+ } else if (stockCapped(payload?.warnings)) {
7044
+ fail("Some items in this bundle are out of stock.");
7045
+ return;
6899
7046
  } else if (payload?.cart) {
6900
7047
  checkoutUrl = payload.cart.checkoutUrl;
6901
7048
  }
@@ -6906,6 +7053,14 @@ var LimeBundleElement = class extends HTMLElement {
6906
7053
  { input: { lines } }
6907
7054
  );
6908
7055
  const payload = res.cartCreate;
7056
+ if (payload?.userErrors?.length) {
7057
+ fail(payload.userErrors[0].message);
7058
+ return;
7059
+ }
7060
+ if (stockCapped(payload?.warnings)) {
7061
+ fail("Some items in this bundle are out of stock.");
7062
+ return;
7063
+ }
6909
7064
  if (payload?.cart) {
6910
7065
  storage?.setItem(key, payload.cart.id);
6911
7066
  checkoutUrl = payload.cart.checkoutUrl;
@@ -6914,13 +7069,7 @@ var LimeBundleElement = class extends HTMLElement {
6914
7069
  if (checkoutUrl) {
6915
7070
  window.location.assign(checkoutUrl);
6916
7071
  } else {
6917
- this.dispatchEvent(
6918
- new CustomEvent("lime-bundle:error", {
6919
- detail: { message: "Cart creation failed", code: "CART_ERROR" },
6920
- bubbles: true,
6921
- composed: true
6922
- })
6923
- );
7072
+ fail("Cart creation failed");
6924
7073
  }
6925
7074
  } catch (err) {
6926
7075
  this.dispatchEvent(
@@ -6935,6 +7084,42 @@ var LimeBundleElement = class extends HTMLElement {
6935
7084
  );
6936
7085
  }
6937
7086
  }
7087
+ /**
7088
+ * Remove this widget's own earlier lines — the ones tagged with the
7089
+ * `_lime_bundle_gid` attribute — from the saved cart, so a shopper who
7090
+ * clicked add three times while deciding checks out with only what they
7091
+ * just built. Lines without the tag were put in the cart by something
7092
+ * else (the host site, a previous non-widget purchase flow) and are
7093
+ * left alone.
7094
+ *
7095
+ * Returns the cart id when the cart is still usable. Returns null after
7096
+ * calling `dropSavedCart` when it isn't: either the id no longer
7097
+ * resolves (expired or merged on Shopify's side), or the removal itself
7098
+ * failed — in both cases a fresh cart is the only way to guarantee the
7099
+ * checkout matches the shopper's current build.
7100
+ */
7101
+ async clearAbandonedBundleLines(client, cartId, dropSavedCart) {
7102
+ const res = await client.query(CART_LINES_QUERY, {
7103
+ cartId
7104
+ });
7105
+ if (!res.cart) {
7106
+ dropSavedCart();
7107
+ return null;
7108
+ }
7109
+ const abandonedLineIds = res.cart.lines.nodes.filter(
7110
+ (line) => line.attributes.some((attr) => attr.key === BUNDLE_GID_ATTRIBUTE)
7111
+ ).map((line) => line.id);
7112
+ if (abandonedLineIds.length === 0) return cartId;
7113
+ const removal = await client.query(
7114
+ CART_LINES_REMOVE_MUTATION,
7115
+ { cartId, lineIds: abandonedLineIds }
7116
+ );
7117
+ if (removal.cartLinesRemove?.userErrors?.length) {
7118
+ dropSavedCart();
7119
+ return null;
7120
+ }
7121
+ return cartId;
7122
+ }
6938
7123
  reportAddToCartEvent(bundle, lines) {
6939
7124
  if (!this.analyticsEnabled || !this.appUrl) return;
6940
7125
  const quantity = lines.reduce((sum, l) => sum + l.quantity, 0);
@@ -6994,44 +7179,28 @@ var LimeBundleElement = class extends HTMLElement {
6994
7179
  const registerCleanup = (fn) => this.renderCleanups.push(fn);
6995
7180
  switch (bundle.bundleType) {
6996
7181
  case "fixed":
6997
- renderFixedBundle(
6998
- container,
6999
- bundle,
7000
- this.outOfStockBehavior,
7001
- dispatch,
7002
- registerCleanup
7003
- );
7182
+ renderFixedBundle(container, bundle, dispatch, registerCleanup);
7004
7183
  break;
7005
7184
  case "mix_match":
7006
7185
  renderMixMatchBundle(
7007
7186
  container,
7008
7187
  bundle,
7009
- this.outOfStockBehavior,
7010
7188
  dispatch,
7011
7189
  registerCleanup,
7012
7190
  this.currentProductHandle
7013
7191
  );
7014
7192
  break;
7015
7193
  case "volume":
7016
- renderVolumeBundle(container, bundle, this.outOfStockBehavior, dispatch);
7194
+ renderVolumeBundle(container, bundle, dispatch, {
7195
+ productId: this.productIdAttr || null,
7196
+ productHandle: this.currentProductHandle
7197
+ });
7017
7198
  break;
7018
7199
  case "bogo":
7019
- renderBogoBundle(
7020
- container,
7021
- bundle,
7022
- this.outOfStockBehavior,
7023
- dispatch,
7024
- registerCleanup
7025
- );
7200
+ renderBogoBundle(container, bundle, dispatch, registerCleanup);
7026
7201
  break;
7027
7202
  case "multi_step":
7028
- renderMultiStepBundle(
7029
- container,
7030
- bundle,
7031
- this.outOfStockBehavior,
7032
- dispatch,
7033
- registerCleanup
7034
- );
7203
+ renderMultiStepBundle(container, bundle, dispatch, registerCleanup);
7035
7204
  break;
7036
7205
  }
7037
7206
  this.shadow.appendChild(container);