@lime-bundles/widget 2.5.1 → 3.0.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
@@ -18,7 +18,9 @@ import {
18
18
 
19
19
  // src/renderers/fixed.ts
20
20
  import {
21
- resolveBundleQty
21
+ resolveBundleQty,
22
+ isVariantFulfillable,
23
+ shouldShowLowStockBadge
22
24
  } from "@lime-bundles/core";
23
25
 
24
26
  // src/dropdown/bind-dropdown.ts
@@ -67,6 +69,20 @@ function firstEnabled(opts) {
67
69
  for (let i = 0; i < opts.length; i++) if (!opts[i].disabled) return i;
68
70
  return -1;
69
71
  }
72
+ function findScrollableAncestor(el2) {
73
+ const win = el2.ownerDocument?.defaultView;
74
+ if (!win) return null;
75
+ let cur = el2.parentElement;
76
+ while (cur && cur !== el2.ownerDocument.body) {
77
+ const style = win.getComputedStyle(cur);
78
+ const overflowY = style.overflowY;
79
+ if (overflowY === "auto" || overflowY === "scroll" || overflowY === "hidden") {
80
+ return cur;
81
+ }
82
+ cur = cur.parentElement;
83
+ }
84
+ return null;
85
+ }
70
86
  var VARIANT_SELECT_CLASSES = [
71
87
  "lb-bundle-variant-select",
72
88
  "lb-mix-match__variant-select"
@@ -172,6 +188,11 @@ function bindDropdown(select) {
172
188
  if (rect.width === 0) return false;
173
189
  const visibleCount = Math.min(optionEls.length || 1, MAX_VISIBLE_ITEMS);
174
190
  const desiredHeight = visibleCount * ITEM_HEIGHT_PX + LIST_PAD_Y;
191
+ const scrollable = listbox.hasAttribute("data-lb-dropdown-portal") ? null : findScrollableAncestor(trigger);
192
+ const clip = scrollable ? (() => {
193
+ const r = scrollable.getBoundingClientRect();
194
+ return { top: r.top, bottom: r.bottom };
195
+ })() : void 0;
175
196
  const result = computePosition({
176
197
  trigger: {
177
198
  top: rect.top,
@@ -180,7 +201,8 @@ function bindDropdown(select) {
180
201
  width: rect.width
181
202
  },
182
203
  viewportHeight: window.innerHeight,
183
- desiredHeight
204
+ desiredHeight,
205
+ clip
184
206
  });
185
207
  listbox.setAttribute("data-placement", result.placement);
186
208
  listbox.style.maxHeight = `${result.maxHeight}px`;
@@ -534,9 +556,18 @@ function renderFixedBundle(container, bundle, onAddToCart, onCleanup) {
534
556
  const list = el("div", "lb-fixed__products");
535
557
  const rowHandles = [];
536
558
  rows.forEach((rowState) => {
537
- const handle = renderProductRow(rowState, currency, qtyFor, () => {
538
- updatePricing();
539
- });
559
+ const handle = renderProductRow(
560
+ rowState,
561
+ currency,
562
+ qtyFor,
563
+ {
564
+ enabled: wc.showLowStockBadge,
565
+ threshold: wc.lowStockThreshold
566
+ },
567
+ () => {
568
+ updatePricing();
569
+ }
570
+ );
540
571
  rowHandles.push(handle);
541
572
  list.appendChild(handle.el);
542
573
  });
@@ -592,7 +623,9 @@ function renderFixedBundle(container, bundle, onAddToCart, onCleanup) {
592
623
  function buildRowState(bundle, product, productIndex) {
593
624
  const selectedVariantIds = bundle.selectedVariantIds?.[productIndex] ?? null;
594
625
  const merchantScoped = selectedVariantIds && selectedVariantIds.length > 0 ? product.variants.nodes.filter((v) => selectedVariantIds.includes(v.id)) : product.variants.nodes;
595
- const firstInStock = merchantScoped.find((v) => v.availableForSale) ?? null;
626
+ const firstInStock = merchantScoped.find(
627
+ (v) => isVariantFulfillable(v, resolveBundleQty(bundle, product.id, v.id))
628
+ ) ?? null;
596
629
  const eligibleVariants = merchantScoped;
597
630
  const isOos = !firstInStock;
598
631
  const selected = firstInStock ?? merchantScoped[0] ?? null;
@@ -656,7 +689,7 @@ function deriveHeaderBadge(bundle, totalCents, saleCents, currency) {
656
689
  }
657
690
  return `-${formatCents(savings, currency)}`;
658
691
  }
659
- function renderProductRow(state, currency, qtyFor, onVariantChange) {
692
+ function renderProductRow(state, currency, qtyFor, lowStock, onVariantChange) {
660
693
  const rowEl = el(
661
694
  "div",
662
695
  state.isOos ? "lb-bundle-product-row lb-bundle-product-row--oos" : "lb-bundle-product-row",
@@ -703,6 +736,12 @@ function renderProductRow(state, currency, qtyFor, onVariantChange) {
703
736
  oosLabel.textContent = "Out of stock";
704
737
  info.appendChild(oosLabel);
705
738
  } else if (state.selected) {
739
+ const isSinglePinnedVariant = state.eligibleVariants.length === 1 && state.product.variants.nodes.length > 1;
740
+ if (isSinglePinnedVariant) {
741
+ const badge = el("span", "lb-bundle-variant-badge");
742
+ badge.textContent = state.eligibleVariants[0].title;
743
+ info.appendChild(badge);
744
+ }
706
745
  const prices = el("span", "lb-bundle-product-prices");
707
746
  const compare = el("span", "lb-bundle-product-compare-price", {
708
747
  "data-product-compare-price": ""
@@ -718,6 +757,11 @@ function renderProductRow(state, currency, qtyFor, onVariantChange) {
718
757
  });
719
758
  unitPriceEl.setAttribute("hidden", "");
720
759
  info.appendChild(unitPriceEl);
760
+ const lowStockEl = el("span", "lb-bundle-low-stock-badge", {
761
+ "data-low-stock-badge": ""
762
+ });
763
+ lowStockEl.setAttribute("hidden", "");
764
+ info.appendChild(lowStockEl);
721
765
  const applyVariantToRow = (variant) => {
722
766
  const unit = parseCents(variant.price.amount);
723
767
  priceEl.textContent = formatCents(unit, currency);
@@ -752,6 +796,18 @@ function renderProductRow(state, currency, qtyFor, onVariantChange) {
752
796
  });
753
797
  thumbImg.alt = nextImage.altText ?? state.product.title;
754
798
  }
799
+ const required = qtyFor(state.product.id, variant.id);
800
+ if (shouldShowLowStockBadge(
801
+ variant,
802
+ required,
803
+ lowStock.threshold,
804
+ lowStock.enabled
805
+ )) {
806
+ lowStockEl.textContent = `Only ${variant.quantityAvailable} left`;
807
+ lowStockEl.removeAttribute("hidden");
808
+ } else {
809
+ lowStockEl.setAttribute("hidden", "");
810
+ }
755
811
  };
756
812
  applyVariantToRow(state.selected);
757
813
  if (state.eligibleVariants.length > 1) {
@@ -770,7 +826,7 @@ function renderProductRow(state, currency, qtyFor, onVariantChange) {
770
826
  });
771
827
  };
772
828
  const isValueAvailable = (optionIndex, value, selected) => state.eligibleVariants.some((v) => {
773
- if (!v.availableForSale) return false;
829
+ if (!isVariantFulfillable(v, qtyFor(state.product.id, v.id))) return false;
774
830
  if (v.selectedOptions[optionIndex]?.value !== value) return false;
775
831
  return v.selectedOptions.every(
776
832
  (o, i) => i === optionIndex || o.value === selected[i]
@@ -834,10 +890,6 @@ function renderProductRow(state, currency, qtyFor, onVariantChange) {
834
890
  if (state.selected) {
835
891
  recomputeDisabled(state.selected.selectedOptions.map((o) => o.value));
836
892
  }
837
- } else if (state.eligibleVariants.length === 1 && state.product.variants.nodes.length > 1) {
838
- const badge = el("span", "lb-bundle-variant-badge");
839
- badge.textContent = state.eligibleVariants[0].title;
840
- info.appendChild(badge);
841
893
  }
842
894
  }
843
895
  rowEl.appendChild(info);
@@ -922,7 +974,12 @@ function computeSale(totalCents, discount, rows) {
922
974
  }
923
975
 
924
976
  // src/renderers/mix-match.ts
925
- import { resolveBundleQty as resolveBundleQty2 } from "@lime-bundles/core";
977
+ import {
978
+ DEFAULT_PRODUCT_RULE,
979
+ isVariantFulfillable as isVariantFulfillable2,
980
+ maxAddableQuantity,
981
+ shouldShowLowStockBadge as shouldShowLowStockBadge2
982
+ } from "@lime-bundles/core";
926
983
  var PLACEHOLDER_THUMB_SVG2 = `
927
984
  <svg class="lb-bundle-placeholder-icon" viewBox="0 0 28 28" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
928
985
  <rect x="4" y="4" width="20" height="20" rx="3"></rect>
@@ -943,18 +1000,36 @@ var SEARCH_CLEAR_ICON_SVG = `
943
1000
  <svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
944
1001
  <path d="M14.348 5.652a.5.5 0 0 0-.707 0L10 9.293 6.36 5.652a.5.5 0 1 0-.708.707L9.293 10l-3.641 3.641a.5.5 0 0 0 .708.707L10 10.707l3.641 3.641a.5.5 0 0 0 .707-.707L10.707 10l3.641-3.641a.5.5 0 0 0 0-.707z"/>
945
1002
  </svg>`;
1003
+ var STEPPER_MINUS_SVG = `
1004
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
1005
+ <line x1="3" y1="7" x2="11" y2="7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
1006
+ </svg>`;
1007
+ var STEPPER_PLUS_SVG = `
1008
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
1009
+ <line x1="7" y1="3" x2="7" y2="11" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
1010
+ <line x1="3" y1="7" x2="11" y2="7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
1011
+ </svg>`;
1012
+ function ruleFor(bundle, productId) {
1013
+ return bundle.productRules[productId] ?? DEFAULT_PRODUCT_RULE;
1014
+ }
1015
+ function alreadyInBundleFor(selections, productId, variantId) {
1016
+ let sum = 0;
1017
+ for (const s of selections) {
1018
+ if (s.productId === productId && s.variantId === variantId) sum += s.quantity;
1019
+ }
1020
+ return sum;
1021
+ }
946
1022
  function renderMixMatchBundle(container, bundle, onAddToCart, onCleanup) {
947
1023
  const wc = bundle.widgetConfig;
948
1024
  const currency = bundle.products[0]?.priceRange.minVariantPrice.currencyCode ?? "USD";
949
1025
  const requiredQty = bundle.minQuantity ?? 1;
950
- const maxQty = bundle.maxQuantity ?? requiredQty;
1026
+ const showQtySelector = wc.mixMatchShowQuantitySelector !== false;
951
1027
  const eligible = buildEligibleProducts(bundle, wc.outOfStockBehavior);
952
1028
  const inStockCount = eligible.filter((e) => !e.isOos).length;
953
1029
  if (inStockCount < requiredQty) return;
954
1030
  const selections = [];
955
1031
  const root = el("div", "lb-mix-match", {
956
- "data-required-quantity": String(requiredQty),
957
- "data-max-quantity": String(maxQty)
1032
+ "data-required-quantity": String(requiredQty)
958
1033
  });
959
1034
  const header = renderHeader2(bundle);
960
1035
  root.appendChild(header);
@@ -988,24 +1063,17 @@ function renderMixMatchBundle(container, bundle, onAddToCart, onCleanup) {
988
1063
  root.appendChild(placeholder);
989
1064
  const modal = renderModal(bundle, eligible, currency, {
990
1065
  showSearch: wc.showSearch,
991
- onAdd: (product, variant) => addSelection(product, variant),
992
- onRemove: (productId, variantId) => removeSelection(productId, variantId),
993
- isOverMax: () => selections.length >= maxQty
1066
+ showQtySelector,
1067
+ selections,
1068
+ onAdd: (product, variant, quantity) => addSelection(product, variant, quantity),
1069
+ isComplete: () => selections.length >= requiredQty
994
1070
  });
995
1071
  root.appendChild(modal.el);
996
1072
  const cta = buildCtaButton(`Select ${requiredQty} items to unlock`);
997
1073
  cta.disabled = true;
998
1074
  cta.addEventListener("click", () => {
999
1075
  if (cta.disabled) return;
1000
- const lines = selections.map((s) => ({
1001
- merchandiseId: s.variantId,
1002
- quantity: s.quantity,
1003
- attributes: [
1004
- { key: "_lime_bundle_gid", value: bundle.id },
1005
- { key: "_lime_bundle_type", value: bundle.bundleType }
1006
- ]
1007
- }));
1008
- onAddToCart(lines);
1076
+ onAddToCart(buildCartLines(selections, bundle));
1009
1077
  });
1010
1078
  root.appendChild(cta);
1011
1079
  root.appendChild(
@@ -1019,28 +1087,17 @@ function renderMixMatchBundle(container, bundle, onAddToCart, onCleanup) {
1019
1087
  );
1020
1088
  container.appendChild(root);
1021
1089
  onCleanup?.(() => unbindAllDropdowns(root));
1022
- const firstEligible = eligible.find((ep) => !ep.isOos);
1023
- const firstVariant = firstEligible?.firstAvailableVariant ?? firstEligible?.variants[0];
1024
- if (firstEligible && firstVariant) {
1025
- selections.push({
1026
- productId: firstEligible.product.id,
1027
- productTitle: firstEligible.product.title,
1028
- variantId: firstVariant.id,
1029
- variantTitle: firstVariant.title,
1030
- imageUrl: firstVariant.image?.url ?? firstEligible.product.featuredImage?.url ?? null,
1031
- priceCents: parseCents(firstVariant.price.amount),
1032
- compareCents: firstVariant.compareAtPrice ? parseCents(firstVariant.compareAtPrice.amount) : null,
1033
- unitPriceLabel: formatUnitPrice(
1034
- firstVariant.unitPrice,
1035
- firstVariant.unitPriceMeasurement,
1036
- currency
1037
- ),
1038
- quantity: resolveBundleQty2(bundle, firstEligible.product.id, firstVariant.id)
1039
- });
1040
- }
1041
1090
  afterMutation();
1042
- function addSelection(product, variant) {
1043
- if (selections.length >= maxQty) return;
1091
+ function addSelection(product, variant, quantity) {
1092
+ if (selections.length >= requiredQty) return;
1093
+ const rule = ruleFor(bundle, product.id);
1094
+ const cap = maxAddableQuantity(
1095
+ variant,
1096
+ rule.max,
1097
+ alreadyInBundleFor(selections, product.id, variant.id)
1098
+ );
1099
+ if (cap < rule.min) return;
1100
+ const clamped = Math.max(rule.min, Math.min(quantity, cap));
1044
1101
  selections.push({
1045
1102
  productId: product.id,
1046
1103
  productTitle: product.title,
@@ -1054,18 +1111,10 @@ function renderMixMatchBundle(container, bundle, onAddToCart, onCleanup) {
1054
1111
  variant.unitPriceMeasurement,
1055
1112
  currency
1056
1113
  ),
1057
- quantity: resolveBundleQty2(bundle, product.id, variant.id)
1114
+ quantity: clamped
1058
1115
  });
1059
1116
  afterMutation();
1060
1117
  }
1061
- function removeSelection(productId, variantId) {
1062
- const idx = selections.findIndex(
1063
- (s) => s.productId === productId && s.variantId === variantId
1064
- );
1065
- if (idx === -1) return;
1066
- selections.splice(idx, 1);
1067
- afterMutation();
1068
- }
1069
1118
  function removeSlotAt(index) {
1070
1119
  if (index < 0 || index >= selections.length) return;
1071
1120
  selections.splice(index, 1);
@@ -1107,6 +1156,26 @@ function renderMixMatchBundle(container, bundle, onAddToCart, onCleanup) {
1107
1156
  }
1108
1157
  }
1109
1158
  }
1159
+ function buildCartLines(selections, bundle) {
1160
+ const grouped = /* @__PURE__ */ new Map();
1161
+ for (const s of selections) {
1162
+ const key = `${s.productId}::${s.variantId}`;
1163
+ const existing = grouped.get(key);
1164
+ if (existing) {
1165
+ existing.quantity += s.quantity;
1166
+ } else {
1167
+ grouped.set(key, { variantId: s.variantId, quantity: s.quantity });
1168
+ }
1169
+ }
1170
+ return Array.from(grouped.values()).map((line) => ({
1171
+ merchandiseId: line.variantId,
1172
+ quantity: line.quantity,
1173
+ attributes: [
1174
+ { key: "_lime_bundle_gid", value: bundle.id },
1175
+ { key: "_lime_bundle_type", value: bundle.bundleType }
1176
+ ]
1177
+ }));
1178
+ }
1110
1179
  function renderHeader2(bundle) {
1111
1180
  const wc = bundle.widgetConfig;
1112
1181
  const header = el("div", "lb-bundle-header");
@@ -1222,7 +1291,7 @@ function renderFilledSlot(selection, index, currency, onRemove) {
1222
1291
  thumb.insertAdjacentHTML("beforeend", PLACEHOLDER_THUMB_SVG2);
1223
1292
  }
1224
1293
  const qtyBadge = el("span", "lb-bundle-qty-badge");
1225
- qtyBadge.textContent = String(selection.quantity);
1294
+ qtyBadge.textContent = `\xD7${selection.quantity}`;
1226
1295
  thumb.appendChild(qtyBadge);
1227
1296
  slot.appendChild(thumb);
1228
1297
  const info = el("div", "lb-mix-match__filled-info");
@@ -1411,8 +1480,9 @@ function renderModal(bundle, eligible, currency, handlers) {
1411
1480
  rowsBuilt = true;
1412
1481
  list.innerHTML = "";
1413
1482
  eligible.forEach((ep) => {
1483
+ const rule = ruleFor(bundle, ep.product.id);
1414
1484
  const availableVariants = ep.variants;
1415
- const firstAvailVariant = ep.variants.find((v) => v.availableForSale) ?? ep.firstAvailableVariant ?? ep.variants[0];
1485
+ const firstAvailVariant = ep.variants.find((v) => isVariantFulfillable2(v, rule.min)) ?? ep.firstAvailableVariant ?? ep.variants[0];
1416
1486
  if (!firstAvailVariant) return;
1417
1487
  let currentVariant = firstAvailVariant;
1418
1488
  const productEl = el(
@@ -1421,7 +1491,7 @@ function renderModal(bundle, eligible, currency, handlers) {
1421
1491
  { "data-product-id": ep.product.id.replace(/^.*\//, "") }
1422
1492
  );
1423
1493
  const thumb = el("div", "lb-mix-match__modal-product-thumb");
1424
- const initialThumbVariant = ep.variants.find((v) => v.availableForSale) ?? ep.variants[0] ?? null;
1494
+ const initialThumbVariant = ep.variants.find((v) => isVariantFulfillable2(v, rule.min)) ?? ep.variants[0] ?? null;
1425
1495
  const initialThumbImage = initialThumbVariant?.image ?? ep.product.featuredImage ?? null;
1426
1496
  let thumbImg = null;
1427
1497
  if (initialThumbImage) {
@@ -1440,9 +1510,7 @@ function renderModal(bundle, eligible, currency, handlers) {
1440
1510
  thumb.insertAdjacentHTML("beforeend", PLACEHOLDER_THUMB_SVG2);
1441
1511
  }
1442
1512
  const countBadge = el("span", "lb-bundle-qty-badge");
1443
- countBadge.textContent = String(
1444
- resolveBundleQty2(bundle, ep.product.id, currentVariant.id)
1445
- );
1513
+ countBadge.hidden = true;
1446
1514
  thumb.appendChild(countBadge);
1447
1515
  productEl.appendChild(thumb);
1448
1516
  const info = el("div", "lb-mix-match__modal-product-info");
@@ -1450,10 +1518,7 @@ function renderModal(bundle, eligible, currency, handlers) {
1450
1518
  title.textContent = ep.product.title;
1451
1519
  info.appendChild(title);
1452
1520
  const price = el("p", "lb-mix-match__modal-product-price");
1453
- price.textContent = formatCents(
1454
- parseCents(currentVariant.price.amount) * resolveBundleQty2(bundle, ep.product.id, currentVariant.id),
1455
- currency
1456
- );
1521
+ price.textContent = formatCents(parseCents(currentVariant.price.amount), currency);
1457
1522
  info.appendChild(price);
1458
1523
  const unitPrice = el(
1459
1524
  "p",
@@ -1470,11 +1535,59 @@ function renderModal(bundle, eligible, currency, handlers) {
1470
1535
  unitPrice.hidden = true;
1471
1536
  }
1472
1537
  info.appendChild(unitPrice);
1538
+ const lowStockBadge = el("span", "lb-bundle-low-stock-badge", {
1539
+ "data-low-stock-badge": ""
1540
+ });
1541
+ lowStockBadge.hidden = true;
1542
+ info.appendChild(lowStockBadge);
1543
+ const refreshLowStockBadge = (variant) => {
1544
+ if (shouldShowLowStockBadge2(
1545
+ variant,
1546
+ rule.min,
1547
+ bundle.widgetConfig.lowStockThreshold,
1548
+ bundle.widgetConfig.showLowStockBadge
1549
+ )) {
1550
+ lowStockBadge.textContent = `Only ${variant.quantityAvailable} left`;
1551
+ lowStockBadge.hidden = false;
1552
+ } else {
1553
+ lowStockBadge.hidden = true;
1554
+ }
1555
+ };
1556
+ refreshLowStockBadge(currentVariant);
1557
+ const computeStepperBounds = () => {
1558
+ const already = alreadyInBundleFor(
1559
+ handlers.selections,
1560
+ ep.product.id,
1561
+ currentVariant.id
1562
+ );
1563
+ const cap = maxAddableQuantity(currentVariant, rule.max, already);
1564
+ const max = Math.max(rule.min, cap);
1565
+ return { min: rule.min, max, cap };
1566
+ };
1567
+ let stepper = null;
1568
+ if (!ep.isOos && handlers.showQtySelector) {
1569
+ const qtyGroup = el(
1570
+ "div",
1571
+ "lb-bundle-variant-option-group lb-mix-match__qty-stepper-group"
1572
+ );
1573
+ const qtyLabel = el("span", "lb-bundle-variant-option-label");
1574
+ qtyLabel.textContent = "Quantity";
1575
+ qtyGroup.appendChild(qtyLabel);
1576
+ stepper = renderQtyStepper({
1577
+ initial: rule.min,
1578
+ getBounds: () => {
1579
+ const { min, max } = computeStepperBounds();
1580
+ return { min, max };
1581
+ }
1582
+ });
1583
+ qtyGroup.appendChild(stepper.el);
1584
+ info.appendChild(qtyGroup);
1585
+ }
1473
1586
  const row = {
1474
1587
  el: productEl,
1475
1588
  product: ep.product,
1476
1589
  variant: firstAvailVariant,
1477
- updateCount: () => {
1590
+ refreshFromSelections: () => {
1478
1591
  }
1479
1592
  };
1480
1593
  if (availableVariants.length > 1) {
@@ -1493,7 +1606,7 @@ function renderModal(bundle, eligible, currency, handlers) {
1493
1606
  });
1494
1607
  };
1495
1608
  const isValueAvailable = (optionIndex, value, selected) => availableVariants.some((v) => {
1496
- if (!v.availableForSale) return false;
1609
+ if (!isVariantFulfillable2(v, rule.min)) return false;
1497
1610
  if (v.selectedOptions[optionIndex]?.value !== value) return false;
1498
1611
  return v.selectedOptions.every(
1499
1612
  (o, i) => i === optionIndex || o.value === selected[i]
@@ -1518,15 +1631,7 @@ function renderModal(bundle, eligible, currency, handlers) {
1518
1631
  }
1519
1632
  currentVariant = next;
1520
1633
  row.variant = next;
1521
- const nextQty = resolveBundleQty2(
1522
- bundle,
1523
- ep.product.id,
1524
- currentVariant.id
1525
- );
1526
- price.textContent = formatCents(
1527
- parseCents(currentVariant.price.amount) * nextQty,
1528
- currency
1529
- );
1634
+ price.textContent = formatCents(parseCents(currentVariant.price.amount), currency);
1530
1635
  const nextUnitText = formatUnitPrice(
1531
1636
  currentVariant.unitPrice,
1532
1637
  currentVariant.unitPriceMeasurement,
@@ -1549,7 +1654,8 @@ function renderModal(bundle, eligible, currency, handlers) {
1549
1654
  thumbImg.alt = nextImage.altText ?? ep.product.title;
1550
1655
  }
1551
1656
  recomputeDisabled(next.selectedOptions.map((o) => o.value));
1552
- rowUpdateCount();
1657
+ refreshLowStockBadge(currentVariant);
1658
+ row.refreshFromSelections();
1553
1659
  };
1554
1660
  const groupsContainer = el("div", "lb-bundle-variant-option-groups");
1555
1661
  optionNames.forEach((name, position) => {
@@ -1596,25 +1702,45 @@ function renderModal(bundle, eligible, currency, handlers) {
1596
1702
  info.appendChild(soldOut);
1597
1703
  }
1598
1704
  productEl.appendChild(info);
1599
- const rowUpdateCount = () => {
1600
- countBadge.textContent = String(
1601
- resolveBundleQty2(bundle, ep.product.id, currentVariant.id)
1705
+ let addBtn = null;
1706
+ const refreshAddState = () => {
1707
+ if (!addBtn) return;
1708
+ const { cap } = computeStepperBounds();
1709
+ const already = alreadyInBundleFor(
1710
+ handlers.selections,
1711
+ ep.product.id,
1712
+ currentVariant.id
1602
1713
  );
1714
+ if (already > 0) {
1715
+ countBadge.textContent = String(already);
1716
+ countBadge.hidden = false;
1717
+ } else {
1718
+ countBadge.hidden = true;
1719
+ }
1720
+ addBtn.disabled = handlers.isComplete() || cap < rule.min;
1603
1721
  };
1604
1722
  if (!ep.isOos) {
1605
- const addBtn = document.createElement("button");
1723
+ const actions = el("div", "lb-mix-match__modal-product-actions");
1724
+ addBtn = document.createElement("button");
1606
1725
  addBtn.type = "button";
1607
1726
  addBtn.className = "lb-mix-match__modal-add";
1608
1727
  addBtn.textContent = "Add";
1609
1728
  addBtn.addEventListener("click", () => {
1610
- if (handlers.isOverMax()) return;
1611
- handlers.onAdd(ep.product, currentVariant);
1729
+ if (!addBtn || addBtn.disabled) return;
1730
+ if (handlers.isComplete()) return;
1731
+ const qty = stepper ? stepper.value() : rule.min;
1732
+ handlers.onAdd(ep.product, currentVariant, qty);
1733
+ if (stepper) stepper.reset(rule.min);
1612
1734
  close();
1613
1735
  });
1614
- productEl.appendChild(addBtn);
1736
+ actions.appendChild(addBtn);
1737
+ productEl.appendChild(actions);
1615
1738
  }
1616
- row.updateCount = ep.isOos ? () => {
1617
- } : rowUpdateCount;
1739
+ row.refreshFromSelections = () => {
1740
+ if (stepper) stepper.refresh();
1741
+ refreshAddState();
1742
+ };
1743
+ row.refreshFromSelections();
1618
1744
  productRows.push(row);
1619
1745
  list.appendChild(productEl);
1620
1746
  });
@@ -1649,7 +1775,7 @@ function renderModal(bundle, eligible, currency, handlers) {
1649
1775
  let isOpen = false;
1650
1776
  function open() {
1651
1777
  if (isOpen) return;
1652
- if (handlers.isOverMax()) return;
1778
+ if (handlers.isComplete()) return;
1653
1779
  isOpen = true;
1654
1780
  buildRows();
1655
1781
  lastFocused = overlay.getRootNode().activeElement;
@@ -1674,17 +1800,73 @@ function renderModal(bundle, eligible, currency, handlers) {
1674
1800
  if (e.target === overlay) close();
1675
1801
  }
1676
1802
  function refreshCounts() {
1677
- productRows.forEach((r) => r.updateCount());
1803
+ productRows.forEach((r) => r.refreshFromSelections());
1678
1804
  }
1679
1805
  return { el: overlay, open, close, refreshCounts };
1680
1806
  }
1807
+ function renderQtyStepper(opts) {
1808
+ const wrap = el("div", "lb-mix-match__qty-stepper", {
1809
+ role: "group",
1810
+ "aria-label": "Quantity"
1811
+ });
1812
+ const minus = document.createElement("button");
1813
+ minus.type = "button";
1814
+ minus.className = "lb-mix-match__qty-stepper-button lb-mix-match__qty-stepper-button--minus";
1815
+ minus.setAttribute("aria-label", "Decrease quantity");
1816
+ minus.innerHTML = STEPPER_MINUS_SVG;
1817
+ wrap.appendChild(minus);
1818
+ const valueEl = el("span", "lb-mix-match__qty-stepper-value", {
1819
+ "aria-live": "polite"
1820
+ });
1821
+ wrap.appendChild(valueEl);
1822
+ const plus = document.createElement("button");
1823
+ plus.type = "button";
1824
+ plus.className = "lb-mix-match__qty-stepper-button lb-mix-match__qty-stepper-button--plus";
1825
+ plus.setAttribute("aria-label", "Increase quantity");
1826
+ plus.innerHTML = STEPPER_PLUS_SVG;
1827
+ wrap.appendChild(plus);
1828
+ let current = clamp2(opts.initial, opts.getBounds());
1829
+ function clamp2(n, b) {
1830
+ return Math.max(b.min, Math.min(b.max, n));
1831
+ }
1832
+ function paint() {
1833
+ const b = opts.getBounds();
1834
+ current = clamp2(current, b);
1835
+ valueEl.textContent = String(current);
1836
+ minus.disabled = current <= b.min;
1837
+ plus.disabled = current >= b.max;
1838
+ }
1839
+ minus.addEventListener("click", () => {
1840
+ const b = opts.getBounds();
1841
+ current = clamp2(current - 1, b);
1842
+ paint();
1843
+ });
1844
+ plus.addEventListener("click", () => {
1845
+ const b = opts.getBounds();
1846
+ current = clamp2(current + 1, b);
1847
+ paint();
1848
+ });
1849
+ paint();
1850
+ return {
1851
+ el: wrap,
1852
+ value: () => current,
1853
+ reset(qty) {
1854
+ current = clamp2(qty, opts.getBounds());
1855
+ paint();
1856
+ },
1857
+ refresh: paint
1858
+ };
1859
+ }
1681
1860
  function buildEligibleProducts(bundle, oosBehavior) {
1682
1861
  const result = [];
1683
1862
  const seen = /* @__PURE__ */ new Set();
1684
1863
  for (const product of bundle.products) {
1685
1864
  if (seen.has(product.id)) continue;
1686
1865
  seen.add(product.id);
1687
- const available = product.variants.nodes.filter((v) => v.availableForSale);
1866
+ const rule = ruleFor(bundle, product.id);
1867
+ const available = product.variants.nodes.filter(
1868
+ (v) => isVariantFulfillable2(v, rule.min)
1869
+ );
1688
1870
  const isOos = available.length === 0;
1689
1871
  if (isOos && oosBehavior === "hide") continue;
1690
1872
  result.push({
@@ -1717,10 +1899,14 @@ function sanitizeId(gid) {
1717
1899
  }
1718
1900
 
1719
1901
  // src/renderers/volume.ts
1902
+ import { isVariantFulfillable as isVariantFulfillable3, shouldShowLowStockBadge as shouldShowLowStockBadge3 } from "@lime-bundles/core";
1720
1903
  function renderVolumeBundle(container, bundle, onAddToCart, onCleanup) {
1721
1904
  const wc = bundle.widgetConfig;
1722
1905
  const product = bundle.products[0];
1723
- const variant = product?.variants.nodes.find((v) => v.availableForSale);
1906
+ const minTierQty = bundle.volumeTiers[0]?.minQuantity ?? 1;
1907
+ const variant = product?.variants.nodes.find(
1908
+ (v) => isVariantFulfillable3(v, minTierQty)
1909
+ );
1724
1910
  if (!variant && wc.outOfStockBehavior === "hide") return;
1725
1911
  const basePriceCents = variant ? parseCents(variant.price.amount) : 0;
1726
1912
  const currency = variant?.price.currencyCode ?? "USD";
@@ -1819,6 +2005,18 @@ function renderVolumeBundle(container, bundle, onAddToCart, onCleanup) {
1819
2005
  }
1820
2006
  ]);
1821
2007
  });
2008
+ if (variant && shouldShowLowStockBadge3(
2009
+ variant,
2010
+ minTierQty,
2011
+ wc.lowStockThreshold,
2012
+ wc.showLowStockBadge
2013
+ )) {
2014
+ const lowStock = el("span", "lb-bundle-low-stock-badge", {
2015
+ "data-low-stock-badge": ""
2016
+ });
2017
+ lowStock.textContent = `Only ${variant.quantityAvailable} left`;
2018
+ root.appendChild(lowStock);
2019
+ }
1822
2020
  root.appendChild(cta);
1823
2021
  root.appendChild(
1824
2022
  el("p", "lb-bundle-error", { "data-error": "", "aria-live": "polite" })
@@ -1972,7 +2170,10 @@ function renderSavingsBar3(resolved, selectedIndex, currency) {
1972
2170
  }
1973
2171
  function renderCta2(bundle, onClick) {
1974
2172
  const product = bundle.products[0];
1975
- const isAvailable = product?.variants.nodes.some((v) => v.availableForSale);
2173
+ const minTierQty = bundle.volumeTiers[0]?.minQuantity ?? 1;
2174
+ const isAvailable = product?.variants.nodes.some(
2175
+ (v) => isVariantFulfillable3(v, minTierQty)
2176
+ );
1976
2177
  const label = isAvailable ? bundle.widgetConfig.cta.ctaText || "Add to cart" : "Sold out";
1977
2178
  const button = buildCtaButton(label);
1978
2179
  if (!isAvailable) button.disabled = true;
@@ -2349,15 +2550,19 @@ var BUNDLE_BASE_CSS = `/* Lime Bundles \u2014 shared base styles for all bundle
2349
2550
  display: none;
2350
2551
  }
2351
2552
 
2352
- .lb-bundle-variant-badge {
2353
- display: inline-block;
2354
- border: var(--lb-variant-border-width) solid var(--lb-variant-border-color);
2355
- border-radius: var(--lb-variant-radius);
2356
- padding: 8px 12px;
2553
+ /* Read-only variant text shown below the product title when only one
2554
+ variant is in scope (single-variant product, or merchant pinned a
2555
+ single variant). Same visual treatment as the mix-match filled
2556
+ slot's variant text \u2014 see .lb-mix-match__filled-variant in
2557
+ bundle-mix-match.css. Both share this rule via the comma selector
2558
+ so the storefront UX stays consistent across bundle types. */
2559
+ .lb-bundle-variant-badge,
2560
+ .lb-mix-match__slot--filled .lb-mix-match__filled-variant {
2561
+ display: block;
2357
2562
  font-size: 12px;
2358
- line-height: 16px;
2563
+ line-height: 20px;
2564
+ margin-top: 2px;
2359
2565
  color: color-mix(in srgb, var(--lb-text) 60%, transparent);
2360
- margin-top: 8px;
2361
2566
  }
2362
2567
 
2363
2568
  /* Per-option variant pickers. Each option's label + select sit inside a
@@ -2631,6 +2836,24 @@ var BUNDLE_BASE_CSS = `/* Lime Bundles \u2014 shared base styles for all bundle
2631
2836
  margin-left: auto;
2632
2837
  }
2633
2838
 
2839
+ /* "Only X left" low-stock badge \u2014 appears alongside product/variant
2840
+ info when ProductVariant.quantityAvailable falls at or below
2841
+ wc.lowStockThreshold. Hidden when quantityAvailable is unknown
2842
+ (Storefront token without read_product_inventory). */
2843
+ .lb-bundle-low-stock-badge {
2844
+ display: inline-flex;
2845
+ align-items: center;
2846
+ gap: 4px;
2847
+ padding: 2px 8px;
2848
+ font-size: 11px;
2849
+ font-weight: 600;
2850
+ line-height: 1.4;
2851
+ color: var(--lb-low-stock-text);
2852
+ background-color: var(--lb-low-stock-bg);
2853
+ border-radius: 4px;
2854
+ white-space: nowrap;
2855
+ }
2856
+
2634
2857
  /* A/B test: hide save badge until JS swaps the label (prevents flash of default) */
2635
2858
  .lb-ab-pending {
2636
2859
  visibility: hidden;
@@ -2854,12 +3077,9 @@ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
2854
3077
  text-decoration: underline;
2855
3078
  }
2856
3079
 
2857
- .lb-mix-match__slot--filled .lb-mix-match__filled-variant {
2858
- font-size: 12px;
2859
- line-height: 20px;
2860
- margin-top: 2px;
2861
- color: color-mix(in srgb, var(--lb-text) 60%, transparent);
2862
- }
3080
+ /* .lb-mix-match__filled-variant \u2014 styled jointly with
3081
+ .lb-bundle-variant-badge above to keep the variant text consistent
3082
+ across mix-match and fixed bundle widgets. */
2863
3083
 
2864
3084
  .lb-mix-match__filled-price {
2865
3085
  /* --lb-product-price-display fallback is the "on" branch; Liquid sets 'none' when merchant disables. */
@@ -3067,8 +3287,8 @@ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
3067
3287
 
3068
3288
  .lb-mix-match__modal-product-thumb {
3069
3289
  position: relative;
3070
- width: 48px;
3071
- min-width: 48px;
3290
+ width: 60px;
3291
+ min-width: 60px;
3072
3292
  /* Modal picker thumbs follow the merchant's pickerThumbnailRatio \u2014
3073
3293
  independent from the main widget's thumbnailRatio so a merchant can
3074
3294
  e.g. show tall picker thumbs with square main thumbs. */
@@ -3117,7 +3337,7 @@ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
3117
3337
  font-size: 12px;
3118
3338
  line-height: 20px;
3119
3339
  color: inherit;
3120
- margin: 4px 0 0;
3340
+ margin: 2px 0 0;
3121
3341
  }
3122
3342
 
3123
3343
  .lb-mix-match__modal-product-unit-price {
@@ -3156,6 +3376,86 @@ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
3156
3376
  outline-offset: 2px;
3157
3377
  }
3158
3378
 
3379
+ /* Per-pick quantity stepper inside the picker modal product row.
3380
+ Three flex cells (\u2212 / number / +) sharing a single outer border \u2014
3381
+ the same picker-variant border vars the variant select uses, so
3382
+ the two pieces visually agree even when the merchant tweaks
3383
+ --lb-picker-variant-border-width / -color / -radius. The cell
3384
+ dividers come from a 1px border on the centre value rather than
3385
+ per-button borders, so the rounded outer corners stay clean.
3386
+ Mirrors extensions/bundle-theme/assets/bundle-mix-match.css so
3387
+ the headless web component matches the Liquid theme widget. */
3388
+ /* Quantity label + stepper sit together in a .lb-bundle-variant-option-group
3389
+ wrapper so the label-to-stepper gap inherits the same 2px the variant
3390
+ pickers use. The wrapper carries the margin-top that spaces the qty
3391
+ group from the unit-price / low-stock-badge above; the stepper itself
3392
+ has no top margin so the group can be repositioned without coupling. */
3393
+ .lb-mix-match__qty-stepper-group {
3394
+ margin-top: 8px;
3395
+ /* Group is a flex column; without an explicit width it stretches to
3396
+ fill the info column so the \u2212/value/+ cells get pulled apart. Pin
3397
+ to fit-content so the wrapper hugs the stepper's natural width. */
3398
+ width: fit-content;
3399
+ }
3400
+
3401
+ .lb-mix-match__qty-stepper {
3402
+ display: inline-flex;
3403
+ align-items: stretch;
3404
+ flex-shrink: 0;
3405
+ height: 32px;
3406
+ border: var(--lb-picker-variant-border-width) solid var(--lb-picker-variant-border-color);
3407
+ border-radius: var(--lb-picker-variant-radius);
3408
+ background-color: var(--lb-picker-bg);
3409
+ overflow: hidden;
3410
+ box-sizing: border-box;
3411
+ }
3412
+
3413
+ .lb-mix-match__qty-stepper-button {
3414
+ appearance: none;
3415
+ -webkit-appearance: none;
3416
+ background: transparent;
3417
+ border: none;
3418
+ margin: 0;
3419
+ padding: 0;
3420
+ width: 28px;
3421
+ font-family: inherit;
3422
+ font-size: 16px;
3423
+ font-weight: 500;
3424
+ line-height: 1;
3425
+ color: var(--lb-picker-text);
3426
+ cursor: pointer;
3427
+ display: inline-flex;
3428
+ align-items: center;
3429
+ justify-content: center;
3430
+ }
3431
+
3432
+ .lb-mix-match__qty-stepper-button:hover:not(:disabled) {
3433
+ background: color-mix(in srgb, var(--lb-picker-text) 6%, transparent);
3434
+ }
3435
+
3436
+ .lb-mix-match__qty-stepper-button:focus-visible {
3437
+ outline: 2px solid var(--lb-primary-color);
3438
+ outline-offset: -2px;
3439
+ }
3440
+
3441
+ .lb-mix-match__qty-stepper-button:disabled {
3442
+ opacity: 0.4;
3443
+ cursor: not-allowed;
3444
+ }
3445
+
3446
+ .lb-mix-match__qty-stepper-value {
3447
+ display: inline-flex;
3448
+ align-items: center;
3449
+ justify-content: center;
3450
+ min-width: 36px;
3451
+ padding: 0 8px;
3452
+ font-family: inherit;
3453
+ font-size: 13px;
3454
+ font-weight: 500;
3455
+ color: var(--lb-picker-text);
3456
+ box-sizing: border-box;
3457
+ }
3458
+
3159
3459
  .lb-mix-match__modal-add {
3160
3460
  padding: 8px 20px;
3161
3461
  background: var(--lb-picker-add-bg);