@lime-bundles/widget 4.1.0 → 4.2.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
@@ -15,8 +15,6 @@ import {
15
15
  hasInContext,
16
16
  withInContext,
17
17
  isVisibleInMarket,
18
- parseOutOfStockBehavior,
19
- DEFAULT_OUT_OF_STOCK_BEHAVIOR,
20
18
  getVisitorId,
21
19
  resolveAbTests
22
20
  } from "@lime-bundles/core";
@@ -25,7 +23,8 @@ import {
25
23
  import {
26
24
  formatUnitPrice,
27
25
  isVariantFulfillable,
28
- resolveBundleQty
26
+ resolveBundleQty,
27
+ stockCapForVariant
29
28
  } from "@lime-bundles/core";
30
29
  function numericId(gid) {
31
30
  const tail = gid.slice(gid.lastIndexOf("/") + 1);
@@ -49,7 +48,10 @@ function adaptVariant(v, requiredQty, fallbackCurrency) {
49
48
  price: toCents(v.price?.amount),
50
49
  compareAtPrice: toCents(v.compareAtPrice?.amount),
51
50
  unitPrice: formatUnitPrice(v.unitPrice, v.unitPriceMeasurement, fallbackCurrency),
52
- image: v.image?.url ?? null
51
+ image: v.image?.url ?? null,
52
+ // Carried so the picker's stepper cap and cross-slot stock guard can
53
+ // bind on the headless surface; dropping it here left them capless.
54
+ inventoryQuantity: stockCapForVariant(v)
53
55
  };
54
56
  }
55
57
  function adaptProduct(product, bundleId, opts) {
@@ -104,6 +106,14 @@ function findVariant(product, variantId) {
104
106
  }
105
107
  return product.variants[0];
106
108
  }
109
+ function resolveSellableVariant(product) {
110
+ const seeded = findVariant(product, product.selectedVariantId);
111
+ if (seeded && seeded.available !== false) return seeded;
112
+ for (let i = 0; i < product.variants.length; i++) {
113
+ if (product.variants[i].available !== false) return product.variants[i];
114
+ }
115
+ return null;
116
+ }
107
117
  function findVariantByOptions(product, optionValues) {
108
118
  if (!product.variants || !optionValues) return null;
109
119
  for (let i = 0; i < product.variants.length; i++) {
@@ -508,7 +518,7 @@ function buildWidgetShell(typeClass, opts) {
508
518
  "data-countdown": ""
509
519
  });
510
520
  const label2 = box("span", "lb-bundle-countdown-label");
511
- label2.textContent = opts.countdownLabel ?? "Ends in";
521
+ label2.textContent = opts.countdownLabel ?? "Limited time offer";
512
522
  countdown.appendChild(label2);
513
523
  countdown.appendChild(
514
524
  box("span", "lb-bundle-countdown-timer", {
@@ -520,10 +530,12 @@ function buildWidgetShell(typeClass, opts) {
520
530
  const products = box("div", typeClass + "__products lb-edge-fade");
521
531
  root.appendChild(products);
522
532
  root.appendChild(box("div", "lb-bundle-divider"));
523
- const summary = box("div", "lb-bundle-summary");
533
+ const summary = box("div", "lb-bundle-summary", {
534
+ "data-pricing-section": ""
535
+ });
524
536
  const text = box("div", "lb-bundle-summary__text");
525
537
  const label = box("span", "lb-bundle-summary__label");
526
- label.textContent = opts.summaryLabel ?? "Bundle price";
538
+ label.textContent = opts.summaryLabel ?? "Bundle total";
527
539
  text.appendChild(label);
528
540
  if (opts.withItemCount) {
529
541
  label.appendChild(box("span", "", { "data-item-count": "" }));
@@ -554,6 +566,7 @@ function buildWidgetShell(typeClass, opts) {
554
566
  summary.appendChild(prices);
555
567
  root.appendChild(summary);
556
568
  const cta = box("button", "lb-bundle-cta", {
569
+ "data-cta-text": opts.ctaText,
557
570
  type: "button",
558
571
  "data-add-bundle": ""
559
572
  });
@@ -572,7 +585,9 @@ function buildWidgetShell(typeClass, opts) {
572
585
  );
573
586
  return { root, products, cta };
574
587
  }
575
- function buildVolumeTiers(tiers, groupLabel) {
588
+ function buildVolumeTiers(tiers, groupLabel, opts = {}) {
589
+ const showCompare = opts.showCompare !== false;
590
+ const showPriceEach = opts.showPriceEach !== false;
576
591
  const group = box("div", "lb-volume__tiers lb-edge-fade", {
577
592
  role: "radiogroup",
578
593
  "aria-label": groupLabel,
@@ -597,8 +612,17 @@ function buildVolumeTiers(tiers, groupLabel) {
597
612
  label.textContent = tier.label;
598
613
  info.appendChild(label);
599
614
  const price = box("span", "lb-volume__tier-price");
600
- price.appendChild(box("span", "lb-volume__tier-compare"));
601
- price.appendChild(box("span", "", { "data-tier-price-each": "" }));
615
+ if (showCompare && (tier.percent > 0 || tier.amountCents > 0)) {
616
+ price.appendChild(box("span", "lb-volume__tier-compare"));
617
+ }
618
+ if (showPriceEach) {
619
+ price.appendChild(box("span", "", { "data-tier-price-each": "" }));
620
+ if (opts.unitLabel) {
621
+ const unit = box("span", "lb-volume__tier-unit");
622
+ unit.textContent = opts.unitLabel;
623
+ price.appendChild(unit);
624
+ }
625
+ }
602
626
  info.appendChild(price);
603
627
  el2.appendChild(info);
604
628
  if (tier.badgeLabel || tier.savingsLabel) {
@@ -707,7 +731,10 @@ var DEFAULT_STRINGS = {
707
731
  required: "Required",
708
732
  soldOut: "Sold out",
709
733
  outOfStock: "Out of stock",
710
- itemsOutOfStock: "__COUNT__ items out of stock",
734
+ itemsOutOfStock: {
735
+ one: "__COUNT__ item out of stock",
736
+ other: "__COUNT__ items out of stock"
737
+ },
711
738
  quantity: "Quantity",
712
739
  increaseQuantity: "Increase quantity",
713
740
  decreaseQuantity: "Decrease quantity",
@@ -727,11 +754,26 @@ var DEFAULT_STRINGS = {
727
754
  stepsCompleted: "__COUNT__ of __TOTAL__ steps completed",
728
755
  each: " each",
729
756
  buyQty: "Buy __COUNT__",
757
+ itemCount: {
758
+ one: "__COUNT__ item",
759
+ other: "__COUNT__ items"
760
+ },
730
761
  needsSpots: {
731
762
  one: "Needs __COUNT__ spot",
732
763
  other: "Needs __COUNT__ spots"
733
764
  }
734
765
  };
766
+ function formatItemsOutOfStock(t, count) {
767
+ const raw = t.itemsOutOfStock;
768
+ let tpl;
769
+ if (raw && typeof raw === "object") {
770
+ const form = new Intl.PluralRules(t.locale || "en").select(count);
771
+ tpl = raw[form] || raw.other;
772
+ } else {
773
+ tpl = raw;
774
+ }
775
+ return (tpl || "__COUNT__ items out of stock").split("__COUNT__").join(String(count));
776
+ }
735
777
 
736
778
  // ../render/src/dropdown/bind.ts
737
779
  import { dropdown } from "@lime-bundles/core";
@@ -1142,12 +1184,11 @@ function bindDropdowns(root) {
1142
1184
  }
1143
1185
 
1144
1186
  // src/renderers/fixed.ts
1145
- function renderFixedBundle(container, bundle, outOfStockBehavior, onAddToCart, onCleanup) {
1187
+ function renderFixedBundle(container, bundle, onAddToCart, onCleanup) {
1146
1188
  const wc = bundle.widgetConfig;
1147
1189
  const products = adaptProducts(bundle);
1148
1190
  if (!products.length) return;
1149
1191
  const oosProducts = products.filter((p) => !p.variants.some((v) => v.available));
1150
- if (outOfStockBehavior === "hide" && oosProducts.length > 0) return;
1151
1192
  const currency = bundle.products[0]?.priceRange.minVariantPrice.currencyCode ?? "USD";
1152
1193
  const formatMoney = intlFormatMoney(currency);
1153
1194
  const t = DEFAULT_STRINGS;
@@ -1172,7 +1213,9 @@ function renderFixedBundle(container, bundle, outOfStockBehavior, onAddToCart, o
1172
1213
  });
1173
1214
  shell.products.appendChild(row);
1174
1215
  if (isOos) continue;
1175
- const selected = findVariant(product, product.selectedVariantId);
1216
+ const sellable = resolveSellableVariant(product);
1217
+ if (sellable) product.selectedVariantId = sellable.id;
1218
+ const selected = sellable ?? findVariant(product, product.selectedVariantId);
1176
1219
  hydrateRowControls(row, product, selected);
1177
1220
  applyVariant(row, selected, product, formatMoney);
1178
1221
  bindVariantSelects(
@@ -1188,7 +1231,7 @@ function renderFixedBundle(container, bundle, outOfStockBehavior, onAddToCart, o
1188
1231
  shell.cta.disabled = true;
1189
1232
  const label = shell.cta.querySelector("[data-cta-label]");
1190
1233
  if (label) {
1191
- label.textContent = (t.itemsOutOfStock || "__COUNT__ items out of stock").split("__COUNT__").join(String(oosProducts.length));
1234
+ label.textContent = formatItemsOutOfStock(t, oosProducts.length);
1192
1235
  }
1193
1236
  } else {
1194
1237
  shell.cta.addEventListener("click", () => {
@@ -1767,6 +1810,17 @@ function unlock() {
1767
1810
  window.scrollTo(0, savedY);
1768
1811
  }
1769
1812
 
1813
+ // ../render/src/picker/sort.ts
1814
+ function sinkAddedRows(list, rowEls, isAdded) {
1815
+ const front = [];
1816
+ const back = [];
1817
+ for (let i = 0; i < rowEls.length; i++) {
1818
+ (isAdded(i) ? back : front).push(rowEls[i]);
1819
+ }
1820
+ for (const el2 of front) list.appendChild(el2);
1821
+ for (const el2 of back) list.appendChild(el2);
1822
+ }
1823
+
1770
1824
  // ../render/src/mix-match/modal.ts
1771
1825
  var ALL_TYPES = "__all__";
1772
1826
  var CLOSE_TRANSITION_MS = 300;
@@ -1991,12 +2045,22 @@ function createPickerModal(deps) {
1991
2045
  syncActiveFilterPill();
1992
2046
  if (searchInput) searchInput.value = "";
1993
2047
  filterProducts(searchInput ? searchInput.value : "");
2048
+ if (modalList) {
2049
+ const selectedProductIds = /* @__PURE__ */ Object.create(null);
2050
+ for (const it of items) selectedProductIds[String(it.productId)] = true;
2051
+ sinkAddedRows(
2052
+ modalList,
2053
+ rows.map((r) => r.el),
2054
+ (i) => selectedProductIds[String(eligibleProducts[i].id)] === true
2055
+ );
2056
+ modalList.scrollTop = 0;
2057
+ }
1994
2058
  overlay.style.display = "";
1995
2059
  void overlay.offsetHeight;
1996
2060
  overlay.classList.add("lb-mix-match__modal-overlay--open");
1997
2061
  lock();
1998
2062
  setTimeout(() => {
1999
- if (searchInput) searchInput.focus();
2063
+ if (closeBtn) closeBtn.focus();
2000
2064
  else modalDialog?.focus();
2001
2065
  }, 50);
2002
2066
  }
@@ -2553,17 +2617,17 @@ function buildPickerModal(opts) {
2553
2617
  }
2554
2618
 
2555
2619
  // src/renderers/mix-match.ts
2556
- function renderMixMatchBundle(container, bundle, outOfStockBehavior, onAddToCart, onCleanup) {
2620
+ function renderMixMatchBundle(container, bundle, onAddToCart, onCleanup, currentProductHandle) {
2557
2621
  const wc = bundle.widgetConfig;
2558
2622
  const t = DEFAULT_STRINGS;
2559
2623
  const requiredQty = bundle.minQuantity || 1;
2560
2624
  const rulesMap = mergeRuleMaps(bundle.productRules, bundle.variantRules);
2561
2625
  const adapted = adaptProducts(bundle);
2562
- const eligibleProducts = adapted.map((p) => ({
2626
+ const eligibleProducts = adapted.map((p, i) => ({
2563
2627
  id: Number(p.productId),
2564
2628
  title: p.title ?? "",
2565
2629
  url: p.url ?? null,
2566
- type: "",
2630
+ type: bundle.products[i]?.productType ?? "",
2567
2631
  featuredImage: p.featuredImage ?? null,
2568
2632
  available: p.variants.some((v) => v.available),
2569
2633
  optionNames: p.optionNames,
@@ -2576,12 +2640,12 @@ function renderMixMatchBundle(container, bundle, outOfStockBehavior, onAddToCart
2576
2640
  compareAtPrice: v.compareAtPrice,
2577
2641
  unitPrice: v.unitPrice,
2578
2642
  image: v.image,
2579
- inventoryQuantity: null
2643
+ inventoryQuantity: v.inventoryQuantity ?? null
2580
2644
  }))
2581
2645
  }));
2582
2646
  if (!eligibleProducts.length) return;
2583
2647
  const poolUnits = eligibleProducts.filter((p) => p.available).length;
2584
- if (outOfStockBehavior === "hide" && poolUnits === 0) return;
2648
+ if (poolUnits === 0) return;
2585
2649
  const formatMoney = intlFormatMoney(
2586
2650
  bundle.products[0]?.priceRange.minVariantPrice.currencyCode ?? "USD"
2587
2651
  );
@@ -2608,16 +2672,21 @@ function renderMixMatchBundle(container, bundle, outOfStockBehavior, onAddToCart
2608
2672
  const overlay = buildPickerModal({
2609
2673
  bundleGid: bundle.id,
2610
2674
  domId: bundle.id.replace(/\D/g, ""),
2611
- showSearch: true,
2612
- showTypeFilters: false,
2675
+ showSearch: wc.showSearch !== false,
2676
+ showTypeFilters: wc.mixMatchShowTypeFilters !== false,
2613
2677
  segmentCount: requiredQty,
2614
2678
  wizard: false
2615
2679
  });
2616
2680
  shell.root.appendChild(overlay);
2681
+ const currentIdx = currentProductHandle ? bundle.products.findIndex((p) => p.handle === currentProductHandle) : -1;
2617
2682
  const selectedItems = seedSelection(
2618
2683
  eligibleProducts,
2619
2684
  rulesMap,
2620
- requiredQty
2685
+ requiredQty,
2686
+ {
2687
+ enabled: !!currentProductHandle,
2688
+ currentProductId: currentIdx !== -1 ? eligibleProducts[currentIdx]?.id ?? null : null
2689
+ }
2621
2690
  );
2622
2691
  const rowCapacity = {
2623
2692
  remainingSpots: () => remainingUnits(selectedItems, requiredQty),
@@ -2654,7 +2723,13 @@ function renderMixMatchBundle(container, bundle, outOfStockBehavior, onAddToCart
2654
2723
  shell.root,
2655
2724
  selectedItems,
2656
2725
  requiredQty,
2657
- bundle,
2726
+ // The shared updater reads the theme data-block shape: discount fields
2727
+ // flat, not nested under discountConfig. Passing the bundle itself left
2728
+ // `discountType` undefined, so the summary never painted.
2729
+ {
2730
+ discountType: bundle.discountConfig.discountType,
2731
+ discountValue: bundle.discountConfig.discountValue
2732
+ },
2658
2733
  (total, type, value) => calculateDiscount(total, type, value),
2659
2734
  updatePricing2(formatMoney)
2660
2735
  );
@@ -2667,7 +2742,7 @@ function renderMixMatchBundle(container, bundle, outOfStockBehavior, onAddToCart
2667
2742
  rulesMap,
2668
2743
  eligibleProducts,
2669
2744
  requiredQty,
2670
- showQtySelector: true,
2745
+ showQtySelector: wc.mixMatchShowQuantitySelector !== false,
2671
2746
  items: selectedItems,
2672
2747
  formatMoney,
2673
2748
  rowCapacity,
@@ -2730,6 +2805,53 @@ function renderMixMatchBundle(container, bundle, outOfStockBehavior, onAddToCart
2730
2805
  onCleanup?.(bindDropdowns(shell.root));
2731
2806
  }
2732
2807
 
2808
+ // src/renderers/volume.ts
2809
+ import {
2810
+ bestValueTierIndex,
2811
+ isVariantFulfillable as isVariantFulfillable2,
2812
+ resolveDefaultTierIndex,
2813
+ resolvePopularTierIndex,
2814
+ stockCapForVariant as stockCapForVariant2
2815
+ } from "@lime-bundles/core";
2816
+
2817
+ // ../render/src/volume/stock.ts
2818
+ var TIER_OOS_CLASS = "lb-volume__tier--oos";
2819
+ function tierQty(el2) {
2820
+ return parseInt(el2.getAttribute("data-tier-qty") ?? "", 10) || 0;
2821
+ }
2822
+ function tierUnavailable(el2) {
2823
+ return el2.getAttribute("aria-disabled") === "true";
2824
+ }
2825
+ function applyTierStockState(tierEls, cap) {
2826
+ for (let i = 0; i < tierEls.length; i++) {
2827
+ const el2 = tierEls[i];
2828
+ const unavailable = cap !== null && tierQty(el2) > cap;
2829
+ if (unavailable) {
2830
+ el2.classList.add(TIER_OOS_CLASS);
2831
+ el2.setAttribute("aria-disabled", "true");
2832
+ } else {
2833
+ el2.classList.remove(TIER_OOS_CLASS);
2834
+ el2.removeAttribute("aria-disabled");
2835
+ }
2836
+ }
2837
+ }
2838
+ function resolveAvailableTierIndex(tierEls, preferred) {
2839
+ const p = tierEls[preferred];
2840
+ if (p && !tierUnavailable(p)) return preferred;
2841
+ for (let i = 0; i < tierEls.length; i++) {
2842
+ if (!tierUnavailable(tierEls[i])) return i;
2843
+ }
2844
+ return -1;
2845
+ }
2846
+ function stepToAvailableTier(tierEls, from, direction) {
2847
+ let i = from + direction;
2848
+ while (i >= 0 && i < tierEls.length) {
2849
+ if (!tierUnavailable(tierEls[i])) return i;
2850
+ i += direction;
2851
+ }
2852
+ return from;
2853
+ }
2854
+
2733
2855
  // ../render/src/volume/pricing.ts
2734
2856
  function calcTierPrice(basePrice, discountType, tierEl) {
2735
2857
  if (discountType === "fixed_amount") {
@@ -2758,6 +2880,23 @@ function formatItemCount(qty, translations) {
2758
2880
  const tpl = translations.itemCount[form] || translations.itemCount.other || "__COUNT__";
2759
2881
  return " (" + tpl.split("__COUNT__").join(String(qty)) + ")";
2760
2882
  }
2883
+ function updateAllTierPrices(allTiers, basePrice, discountType, formatMoney) {
2884
+ for (let i = 0; i < allTiers.length; i++) {
2885
+ const tierEl = allTiers[i];
2886
+ const priceEachEl = tierEl.querySelector(
2887
+ "[data-tier-price-each]"
2888
+ );
2889
+ if (priceEachEl) {
2890
+ priceEachEl.textContent = formatMoney(
2891
+ calcTierPrice(basePrice, discountType, tierEl)
2892
+ );
2893
+ }
2894
+ const compareEl = tierEl.querySelector(
2895
+ ".lb-volume__tier-compare"
2896
+ );
2897
+ if (compareEl) compareEl.textContent = formatMoney(basePrice);
2898
+ }
2899
+ }
2761
2900
  function selectTier(container, allTiers, basePrice, discountType, index, translations, formatMoney) {
2762
2901
  for (let i = 0; i < allTiers.length; i++) {
2763
2902
  allTiers[i].setAttribute("aria-checked", i === index ? "true" : "false");
@@ -2802,21 +2941,31 @@ function selectTier(container, allTiers, basePrice, discountType, index, transla
2802
2941
  }
2803
2942
 
2804
2943
  // src/renderers/volume.ts
2805
- function renderVolumeBundle(container, bundle, outOfStockBehavior, onAddToCart) {
2944
+ function renderVolumeBundle(container, bundle, onAddToCart) {
2806
2945
  const wc = bundle.widgetConfig;
2807
2946
  const product = bundle.products[0];
2808
2947
  if (!product) return;
2809
2948
  const variants = product.variants.nodes;
2810
- const pricingVariant = variants.find((v) => v.availableForSale) ?? variants[0];
2949
+ const minTierQty = Math.min(
2950
+ ...bundle.volumeTiers.map((tier) => tier.minQuantity)
2951
+ );
2952
+ const pricingVariant = variants.find((v) => isVariantFulfillable2(v, minTierQty)) ?? variants.find((v) => v.availableForSale) ?? variants[0];
2811
2953
  if (!pricingVariant) return;
2812
- const anyAvailable = variants.some((v) => v.availableForSale);
2813
- if (outOfStockBehavior === "hide" && !anyAvailable) return;
2814
2954
  const basePrice = toCents(pricingVariant.price.amount);
2815
2955
  const discountType = bundle.discountConfig.discountType;
2816
2956
  const formatMoney = intlFormatMoney(product.priceRange.minVariantPrice.currencyCode);
2817
2957
  const t = DEFAULT_STRINGS;
2818
- const popularIdx = wc.popularBadge?.visible ? Math.floor((bundle.volumeTiers.length - 1) / 2) : -1;
2819
- const defaultIdx = 0;
2958
+ const bestIdx = bestValueTierIndex(bundle.volumeTiers, discountType);
2959
+ const defaultIdx = resolveDefaultTierIndex(
2960
+ wc.defaultTier,
2961
+ bundle.volumeTiers.length,
2962
+ bestIdx
2963
+ );
2964
+ const popularIdx = resolvePopularTierIndex(
2965
+ wc.popularBadge?.tierIndex,
2966
+ wc.popularBadge?.visible !== false,
2967
+ bestIdx
2968
+ );
2820
2969
  const tiers = bundle.volumeTiers.map((tier, i) => {
2821
2970
  const percent = discountType === "fixed_amount" ? 0 : Math.round(tier.percentage ?? 0);
2822
2971
  const amountCents = discountType === "fixed_amount" ? Math.round((tier.amount ?? 0) * 100) : 0;
@@ -2837,38 +2986,46 @@ function renderVolumeBundle(container, bundle, outOfStockBehavior, onAddToCart)
2837
2986
  title: bundle.title,
2838
2987
  subtitle: bundle.description,
2839
2988
  summaryLabel: "Total",
2840
- withItemCount: true,
2989
+ withItemCount: wc.pricing?.showItemCount !== false,
2841
2990
  totalPriceSlot: true,
2842
2991
  showSavingsBar: wc.savingsBar?.visible !== false,
2843
2992
  showComparePrice: wc.pricing?.showCompareAtPrice !== false,
2844
2993
  ctaText: wc.cta?.ctaText || t.addToCart || "Add to cart",
2845
2994
  endsAt: wc.countdown?.showCountdown === false ? null : bundle.endsAt
2846
2995
  });
2847
- const group = buildVolumeTiers(tiers, "Select quantity");
2996
+ const group = buildVolumeTiers(tiers, "Select quantity", {
2997
+ showCompare: wc.pricing?.showComparePrice !== false,
2998
+ showPriceEach: wc.pricing?.showPerUnitPrice !== false,
2999
+ unitLabel: (t.each || "each").trim()
3000
+ });
2848
3001
  shell.products.replaceWith(group);
2849
3002
  const tierEls = group.querySelectorAll("[data-tier-index]");
2850
- let selectedIndex = defaultIdx;
3003
+ updateAllTierPrices(tierEls, basePrice, discountType, formatMoney);
3004
+ applyTierStockState(tierEls, stockCapForVariant2(pricingVariant));
3005
+ let selectedIndex = resolveAvailableTierIndex(tierEls, defaultIdx);
3006
+ const noTierAvailable = selectedIndex === -1;
3007
+ if (noTierAvailable) selectedIndex = defaultIdx;
2851
3008
  const apply = () => selectTier(shell.root, tierEls, basePrice, discountType, selectedIndex, t, formatMoney);
2852
3009
  group.addEventListener("click", (e) => {
2853
3010
  const el2 = e.target.closest("[data-tier-index]");
2854
- if (!el2) return;
3011
+ if (!el2 || tierUnavailable(el2)) return;
2855
3012
  selectedIndex = parseInt(el2.getAttribute("data-tier-index") ?? "", 10) || 0;
2856
3013
  apply();
2857
3014
  });
2858
3015
  group.addEventListener("keydown", (e) => {
2859
3016
  if (e.key === "ArrowDown" || e.key === "ArrowRight") {
2860
3017
  e.preventDefault();
2861
- selectedIndex = Math.min(selectedIndex + 1, tierEls.length - 1);
3018
+ selectedIndex = stepToAvailableTier(tierEls, selectedIndex, 1);
2862
3019
  apply();
2863
3020
  tierEls[selectedIndex].focus();
2864
3021
  } else if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
2865
3022
  e.preventDefault();
2866
- selectedIndex = Math.max(selectedIndex - 1, 0);
3023
+ selectedIndex = stepToAvailableTier(tierEls, selectedIndex, -1);
2867
3024
  apply();
2868
3025
  tierEls[selectedIndex].focus();
2869
3026
  }
2870
3027
  });
2871
- if (!anyAvailable) {
3028
+ if (noTierAvailable) {
2872
3029
  shell.cta.disabled = true;
2873
3030
  const label = shell.cta.querySelector("[data-cta-label]");
2874
3031
  if (label) label.textContent = t.outOfStock || "Out of stock";
@@ -2996,7 +3153,14 @@ function buildBogoCartItems(sides, percent, buyQty, getQty) {
2996
3153
  }
2997
3154
 
2998
3155
  // src/renderers/bogo.ts
2999
- function renderBogoBundle(container, bundle, outOfStockBehavior, onAddToCart, onCleanup) {
3156
+ function buildBogoPlus() {
3157
+ const plus = document.createElement("div");
3158
+ plus.className = "lb-bogo__plus";
3159
+ plus.setAttribute("aria-hidden", "true");
3160
+ 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>';
3161
+ return plus;
3162
+ }
3163
+ function renderBogoBundle(container, bundle, onAddToCart, onCleanup) {
3000
3164
  const wc = bundle.widgetConfig;
3001
3165
  const buyProduct = bundle.products.find((p) => p.id === bundle.buyProductId);
3002
3166
  const getProduct = bundle.products.find((p) => p.id === bundle.getProductId);
@@ -3025,7 +3189,6 @@ function renderBogoBundle(container, bundle, outOfStockBehavior, onAddToCart, on
3025
3189
  }
3026
3190
  ];
3027
3191
  const oosCount = sides.filter((s) => !s.variants.some((v) => v.available)).length;
3028
- if (outOfStockBehavior === "hide" && oosCount > 0) return;
3029
3192
  const formatMoney = intlFormatMoney(
3030
3193
  buyProduct.priceRange.minVariantPrice.currencyCode
3031
3194
  );
@@ -3044,6 +3207,7 @@ function renderBogoBundle(container, bundle, outOfStockBehavior, onAddToCart, on
3044
3207
  const badgeText = percent >= 100 ? "Free" : percent + "% off";
3045
3208
  sides.forEach((side, i) => {
3046
3209
  const isGet = i === 1;
3210
+ if (isGet) shell.products.appendChild(buildBogoPlus());
3047
3211
  const isOos = !side.variants.some((v) => v.available);
3048
3212
  const row = buildRowSkeleton(side, t, {
3049
3213
  isOos,
@@ -3072,7 +3236,7 @@ function renderBogoBundle(container, bundle, outOfStockBehavior, onAddToCart, on
3072
3236
  shell.cta.disabled = true;
3073
3237
  const label = shell.cta.querySelector("[data-cta-label]");
3074
3238
  if (label) {
3075
- label.textContent = (t.itemsOutOfStock || "__COUNT__ items out of stock").split("__COUNT__").join(String(oosCount));
3239
+ label.textContent = formatItemsOutOfStock(t, oosCount);
3076
3240
  }
3077
3241
  } else {
3078
3242
  shell.cta.addEventListener("click", () => {
@@ -3460,6 +3624,14 @@ function createWizard(deps) {
3460
3624
  rows.push(row);
3461
3625
  modalList.appendChild(row.el);
3462
3626
  }
3627
+ const stepPicks = selections[forStep] || [];
3628
+ const selectedProductIds = /* @__PURE__ */ Object.create(null);
3629
+ for (const it of stepPicks) selectedProductIds[String(it.productId)] = true;
3630
+ sinkAddedRows(
3631
+ modalList,
3632
+ rows.map((r) => r.el),
3633
+ (i) => selectedProductIds[String(pool[i].id)] === true
3634
+ );
3463
3635
  deps.dropdown?.bindAll(modalList);
3464
3636
  }
3465
3637
  function onListClick(e) {
@@ -3622,6 +3794,7 @@ function createWizard(deps) {
3622
3794
  syncActiveFilterPill();
3623
3795
  if (searchInput) searchInput.value = "";
3624
3796
  filterProducts("");
3797
+ if (modalList) modalList.scrollTop = 0;
3625
3798
  buildStepSegments(stepIndex);
3626
3799
  refresh();
3627
3800
  if (modalLive) {
@@ -3645,7 +3818,7 @@ function createWizard(deps) {
3645
3818
  overlay.classList.add("lb-mix-match__modal-overlay--open");
3646
3819
  lock();
3647
3820
  setTimeout(() => {
3648
- if (searchInput) searchInput.focus();
3821
+ if (closeBtn) closeBtn.focus();
3649
3822
  else modalDialog?.focus();
3650
3823
  }, 50);
3651
3824
  }
@@ -3713,7 +3886,7 @@ function createWizard(deps) {
3713
3886
  }
3714
3887
 
3715
3888
  // src/renderers/multi-step.ts
3716
- function renderMultiStepBundle(container, bundle, outOfStockBehavior, onAddToCart, onCleanup) {
3889
+ function renderMultiStepBundle(container, bundle, onAddToCart, onCleanup) {
3717
3890
  const wc = bundle.widgetConfig;
3718
3891
  const t = DEFAULT_STRINGS;
3719
3892
  const rulesMap = mergeRuleMaps(bundle.productRules, bundle.variantRules);
@@ -3746,13 +3919,13 @@ function renderMultiStepBundle(container, bundle, outOfStockBehavior, onAddToCar
3746
3919
  compareAtPrice: v.compareAtPrice,
3747
3920
  unitPrice: v.unitPrice,
3748
3921
  image: v.image,
3749
- inventoryQuantity: null
3922
+ inventoryQuantity: v.inventoryQuantity ?? null
3750
3923
  }))
3751
3924
  };
3752
3925
  })
3753
3926
  );
3754
- const anyPickable = pools.some((pool) => pool.some((p) => p.available));
3755
- if (outOfStockBehavior === "hide" && !anyPickable) return;
3927
+ const everyStepPickable = pools.length > 0 && pools.every((pool) => pool.some((p) => p.available));
3928
+ if (!everyStepPickable) return;
3756
3929
  const formatMoney = intlFormatMoney(
3757
3930
  bundle.products[0]?.priceRange.minVariantPrice.currencyCode ?? "USD"
3758
3931
  );
@@ -3798,8 +3971,8 @@ function renderMultiStepBundle(container, bundle, outOfStockBehavior, onAddToCar
3798
3971
  const overlay = buildPickerModal({
3799
3972
  bundleGid: bundle.id,
3800
3973
  domId: bundle.id.replace(/\D/g, ""),
3801
- showSearch: true,
3802
- showTypeFilters: true,
3974
+ showSearch: wc.showSearch !== false,
3975
+ showTypeFilters: wc.mixMatchShowTypeFilters !== false,
3803
3976
  segmentCount: null,
3804
3977
  wizard: true
3805
3978
  });
@@ -3846,8 +4019,8 @@ function renderMultiStepBundle(container, bundle, outOfStockBehavior, onAddToCar
3846
4019
  selections,
3847
4020
  t,
3848
4021
  rulesMap,
3849
- showQtySelector: true,
3850
- showTypeFilters: true,
4022
+ showQtySelector: wc.mixMatchShowQuantitySelector !== false,
4023
+ showTypeFilters: wc.mixMatchShowTypeFilters !== false,
3851
4024
  formatMoney,
3852
4025
  dropdown: void 0,
3853
4026
  portalTarget: null,
@@ -5564,7 +5737,7 @@ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
5564
5737
  transition: opacity 0.25s ease, background 0.25s ease, color 0.25s ease, border-color 0.25s ease;
5565
5738
  }
5566
5739
 
5567
- .lb-mix-match__modal-add:hover {
5740
+ .lb-mix-match__modal-add:hover:not(:disabled) {
5568
5741
  opacity: 0.9;
5569
5742
  }
5570
5743
 
@@ -5679,8 +5852,15 @@ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
5679
5852
  color: color-mix(in srgb, var(--lb-picker-text) 70%, transparent);
5680
5853
  }
5681
5854
 
5682
- /* "Done" reuses the picker Add-button styling so it matches the Add buttons. */
5855
+ /* "Done" reuses the picker Add-button styling so it matches the Add buttons.
5856
+ The flex centring + min-height pin the footer-button silhouette: the wizard
5857
+ Back (bundle-multi-step.css) copies these metrics so the pair stays
5858
+ equal-height whatever border widths the merchant's picker tokens set. */
5683
5859
  .lb-mix-match__modal-done {
5860
+ display: inline-flex;
5861
+ align-items: center;
5862
+ justify-content: center;
5863
+ min-height: 40px;
5684
5864
  padding: 10px 24px;
5685
5865
  transition: opacity 0.25s ease;
5686
5866
  background: var(--lb-picker-add-bg);
@@ -5694,7 +5874,7 @@ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
5694
5874
  cursor: pointer;
5695
5875
  }
5696
5876
 
5697
- .lb-mix-match__modal-done:hover {
5877
+ .lb-mix-match__modal-done:hover:not(:disabled) {
5698
5878
  opacity: 0.9;
5699
5879
  }
5700
5880
 
@@ -5704,6 +5884,16 @@ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
5704
5884
  box-shadow: none;
5705
5885
  }
5706
5886
 
5887
+ /* Disabled Next/Done (wizard step minimum not met) \u2014 same washed-out
5888
+ treatment as a disabled row Add, so an unfinished step reads at a
5889
+ glance. Hover is gated off above; the solid button returns the moment
5890
+ the requirement is met. */
5891
+ .lb-mix-match__modal-done:disabled {
5892
+ background: color-mix(in srgb, var(--lb-picker-add-bg) 35%, var(--lb-picker-bg));
5893
+ color: color-mix(in srgb, var(--lb-picker-add-label) 85%, transparent);
5894
+ cursor: not-allowed;
5895
+ }
5896
+
5707
5897
  /* Hidden utility for search filtering */
5708
5898
  .lb-hidden {
5709
5899
  display: none !important;
@@ -5960,6 +6150,13 @@ var BUNDLE_VOLUME_CSS = `/* Lime Bundles \u2014 Volume / Quantity Breaks styles
5960
6150
  white-space: nowrap;
5961
6151
  font-variant-numeric: tabular-nums;
5962
6152
  }
6153
+
6154
+ /* Tier the selected variant can't cover \u2014 same greyed treatment as an
6155
+ out-of-stock product row, and not selectable. */
6156
+ .lb-volume__tier--oos {
6157
+ opacity: 0.5;
6158
+ cursor: not-allowed;
6159
+ }
5963
6160
  `;
5964
6161
  var BUNDLE_BOGO_CSS = `/* Lime Bundles \u2014 BOGO (Buy X Get Y) bundle styles */
5965
6162
 
@@ -6128,14 +6325,23 @@ var BUNDLE_MULTI_STEP_CSS = `/**
6128
6325
  justify-self: end;
6129
6326
  }
6130
6327
 
6131
- /* Back \u2014 secondary treatment beside the primary Next/Done (which reuse
6132
- .lb-mix-match__modal-done). */
6328
+ /* Back \u2014 secondary (outlined) treatment beside the primary Next/Done
6329
+ (which reuse .lb-mix-match__modal-done). Same silhouette as Next \u2014
6330
+ metrics copied from the modal-done recipe (padding, type, radius,
6331
+ min-height) \u2014 and picker tokens only: the modal is portaled out of the
6332
+ widget, so the widget vars (--lb-text, --lb-border-*) it previously
6333
+ used don't reliably resolve here and break palette isolation. */
6133
6334
  .lb-multi-step__modal-back {
6134
- padding: 10px 16px;
6335
+ display: inline-flex;
6336
+ align-items: center;
6337
+ justify-content: center;
6338
+ min-height: 40px;
6339
+ padding: 10px 24px;
6135
6340
  background: none;
6136
- border: var(--lb-border-width) solid var(--lb-border-color);
6137
- border-radius: var(--lb-radius-sm);
6138
- color: var(--lb-text);
6341
+ border: var(--lb-picker-border-width) solid var(--lb-picker-border-color);
6342
+ border-radius: var(--lb-picker-radius-sm);
6343
+ box-sizing: border-box;
6344
+ color: var(--lb-picker-text);
6139
6345
  font-family: inherit;
6140
6346
  font-size: 14px;
6141
6347
  font-weight: 600;
@@ -6144,7 +6350,7 @@ var BUNDLE_MULTI_STEP_CSS = `/**
6144
6350
  }
6145
6351
 
6146
6352
  .lb-multi-step__modal-back:hover {
6147
- background: color-mix(in srgb, var(--lb-text) 5%, transparent);
6353
+ background: color-mix(in srgb, var(--lb-picker-text) 5%, transparent);
6148
6354
  }
6149
6355
 
6150
6356
  .lb-multi-step__modal-back:focus-visible {
@@ -6553,6 +6759,13 @@ var LimeBundleElement = class extends HTMLElement {
6553
6759
  buyer = void 0;
6554
6760
  shadow;
6555
6761
  bundles = [];
6762
+ /**
6763
+ * The product handle this embed is standing on, when one can be resolved
6764
+ * (attribute → meta tag → /products/<handle> path). The mix-and-match
6765
+ * courtesy seed uses it to pre-add the current product the way the theme
6766
+ * widget does; null on pages with no product context.
6767
+ */
6768
+ currentProductHandle = null;
6556
6769
  abortController = null;
6557
6770
  impressionCleanups = [];
6558
6771
  /**
@@ -6567,12 +6780,6 @@ var LimeBundleElement = class extends HTMLElement {
6567
6780
  * `.lb-bundle-widget { ... }` reach the widget's DOM.
6568
6781
  */
6569
6782
  shopCustomCss = null;
6570
- /**
6571
- * Shop-wide out-of-stock behaviour, resolved from the `$app` shop metafield
6572
- * before the first render. Defaults to `show_greyed_out` so a failed or
6573
- * missing fetch degrades to "bundles still render".
6574
- */
6575
- outOfStockBehavior = DEFAULT_OUT_OF_STOCK_BEHAVIOR;
6576
6783
  constructor() {
6577
6784
  super();
6578
6785
  this.shadow = this.attachShadow({ mode: "open" });
@@ -6672,11 +6879,12 @@ var LimeBundleElement = class extends HTMLElement {
6672
6879
  try {
6673
6880
  let bundlePromise;
6674
6881
  let singleBundleMode = false;
6882
+ this.currentProductHandle = resolveProductHandle(this.productHandleAttr);
6675
6883
  if (this.bundleGid) {
6676
6884
  singleBundleMode = true;
6677
6885
  bundlePromise = this.fetchSingleBundle(client, controller.signal);
6678
6886
  } else {
6679
- const handle = resolveProductHandle(this.productHandleAttr);
6887
+ const handle = this.currentProductHandle;
6680
6888
  if (!handle) {
6681
6889
  this.teardownImpressions();
6682
6890
  this.renderError(
@@ -6707,9 +6915,6 @@ var LimeBundleElement = class extends HTMLElement {
6707
6915
  const sanitized = sanitizeCustomCss(customCss);
6708
6916
  if (sanitized.ok) this.shopCustomCss = sanitized.css;
6709
6917
  }
6710
- this.outOfStockBehavior = parseOutOfStockBehavior(
6711
- shopSettings?.shop?.outOfStockBehavior?.value
6712
- );
6713
6918
  this.renderBundles();
6714
6919
  } catch (err) {
6715
6920
  if (controller.signal.aborted) return;
@@ -6789,6 +6994,17 @@ var LimeBundleElement = class extends HTMLElement {
6789
6994
  const storage = window.localStorage;
6790
6995
  const key = cartStorageKey(this.shopDomain);
6791
6996
  const existingCartId = storage?.getItem(key) ?? null;
6997
+ const isStaleCartError = (errors) => errors.length > 0 && errors.every((e) => (e.field ?? []).includes("cartId"));
6998
+ const stockCapped = (warnings) => (warnings ?? []).some((w) => w.code === "MERCHANDISE_NOT_ENOUGH_STOCK");
6999
+ const fail = (message) => {
7000
+ this.dispatchEvent(
7001
+ new CustomEvent("lime-bundle:error", {
7002
+ detail: { message, code: "CART_ERROR" },
7003
+ bubbles: true,
7004
+ composed: true
7005
+ })
7006
+ );
7007
+ };
6792
7008
  try {
6793
7009
  let checkoutUrl = null;
6794
7010
  if (existingCartId) {
@@ -6798,7 +7014,14 @@ var LimeBundleElement = class extends HTMLElement {
6798
7014
  );
6799
7015
  const payload = res.cartLinesAdd;
6800
7016
  if (payload?.userErrors?.length) {
7017
+ if (!isStaleCartError(payload.userErrors)) {
7018
+ fail(payload.userErrors[0].message);
7019
+ return;
7020
+ }
6801
7021
  storage?.removeItem(key);
7022
+ } else if (stockCapped(payload?.warnings)) {
7023
+ fail("Some items in this bundle are out of stock.");
7024
+ return;
6802
7025
  } else if (payload?.cart) {
6803
7026
  checkoutUrl = payload.cart.checkoutUrl;
6804
7027
  }
@@ -6809,6 +7032,14 @@ var LimeBundleElement = class extends HTMLElement {
6809
7032
  { input: { lines } }
6810
7033
  );
6811
7034
  const payload = res.cartCreate;
7035
+ if (payload?.userErrors?.length) {
7036
+ fail(payload.userErrors[0].message);
7037
+ return;
7038
+ }
7039
+ if (stockCapped(payload?.warnings)) {
7040
+ fail("Some items in this bundle are out of stock.");
7041
+ return;
7042
+ }
6812
7043
  if (payload?.cart) {
6813
7044
  storage?.setItem(key, payload.cart.id);
6814
7045
  checkoutUrl = payload.cart.checkoutUrl;
@@ -6817,13 +7048,7 @@ var LimeBundleElement = class extends HTMLElement {
6817
7048
  if (checkoutUrl) {
6818
7049
  window.location.assign(checkoutUrl);
6819
7050
  } else {
6820
- this.dispatchEvent(
6821
- new CustomEvent("lime-bundle:error", {
6822
- detail: { message: "Cart creation failed", code: "CART_ERROR" },
6823
- bubbles: true,
6824
- composed: true
6825
- })
6826
- );
7051
+ fail("Cart creation failed");
6827
7052
  }
6828
7053
  } catch (err) {
6829
7054
  this.dispatchEvent(
@@ -6890,49 +7115,32 @@ var LimeBundleElement = class extends HTMLElement {
6890
7115
  container.setAttribute("aria-label", bundle.title);
6891
7116
  container.setAttribute("data-bundle-type", bundle.bundleType);
6892
7117
  container.setAttribute("data-bundle-gid", bundle.id);
7118
+ if (bundle.endsAt) container.setAttribute("data-ends-at", bundle.endsAt);
6893
7119
  applyWidgetConfigVars(container, bundle.widgetConfig);
6894
7120
  this.renderCleanups.push(trackInputMode(container));
6895
7121
  const dispatch = (lines) => this.handleAddToCart(bundle, lines);
6896
7122
  const registerCleanup = (fn) => this.renderCleanups.push(fn);
6897
7123
  switch (bundle.bundleType) {
6898
7124
  case "fixed":
6899
- renderFixedBundle(
6900
- container,
6901
- bundle,
6902
- this.outOfStockBehavior,
6903
- dispatch,
6904
- registerCleanup
6905
- );
7125
+ renderFixedBundle(container, bundle, dispatch, registerCleanup);
6906
7126
  break;
6907
7127
  case "mix_match":
6908
7128
  renderMixMatchBundle(
6909
7129
  container,
6910
7130
  bundle,
6911
- this.outOfStockBehavior,
6912
7131
  dispatch,
6913
- registerCleanup
7132
+ registerCleanup,
7133
+ this.currentProductHandle
6914
7134
  );
6915
7135
  break;
6916
7136
  case "volume":
6917
- renderVolumeBundle(container, bundle, this.outOfStockBehavior, dispatch);
7137
+ renderVolumeBundle(container, bundle, dispatch);
6918
7138
  break;
6919
7139
  case "bogo":
6920
- renderBogoBundle(
6921
- container,
6922
- bundle,
6923
- this.outOfStockBehavior,
6924
- dispatch,
6925
- registerCleanup
6926
- );
7140
+ renderBogoBundle(container, bundle, dispatch, registerCleanup);
6927
7141
  break;
6928
7142
  case "multi_step":
6929
- renderMultiStepBundle(
6930
- container,
6931
- bundle,
6932
- this.outOfStockBehavior,
6933
- dispatch,
6934
- registerCleanup
6935
- );
7143
+ renderMultiStepBundle(container, bundle, dispatch, registerCleanup);
6936
7144
  break;
6937
7145
  }
6938
7146
  this.shadow.appendChild(container);