@lime-bundles/widget 4.0.0 → 4.1.1

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
@@ -16,7 +16,9 @@ import {
16
16
  withInContext,
17
17
  isVisibleInMarket,
18
18
  parseOutOfStockBehavior,
19
- DEFAULT_OUT_OF_STOCK_BEHAVIOR
19
+ DEFAULT_OUT_OF_STOCK_BEHAVIOR,
20
+ getVisitorId,
21
+ resolveAbTests
20
22
  } from "@lime-bundles/core";
21
23
 
22
24
  // ../render/src/adapt.ts
@@ -506,7 +508,7 @@ function buildWidgetShell(typeClass, opts) {
506
508
  "data-countdown": ""
507
509
  });
508
510
  const label2 = box("span", "lb-bundle-countdown-label");
509
- label2.textContent = opts.countdownLabel ?? "Ends in";
511
+ label2.textContent = opts.countdownLabel ?? "Limited time offer";
510
512
  countdown.appendChild(label2);
511
513
  countdown.appendChild(
512
514
  box("span", "lb-bundle-countdown-timer", {
@@ -518,10 +520,12 @@ function buildWidgetShell(typeClass, opts) {
518
520
  const products = box("div", typeClass + "__products lb-edge-fade");
519
521
  root.appendChild(products);
520
522
  root.appendChild(box("div", "lb-bundle-divider"));
521
- const summary = box("div", "lb-bundle-summary");
523
+ const summary = box("div", "lb-bundle-summary", {
524
+ "data-pricing-section": ""
525
+ });
522
526
  const text = box("div", "lb-bundle-summary__text");
523
527
  const label = box("span", "lb-bundle-summary__label");
524
- label.textContent = opts.summaryLabel ?? "Bundle price";
528
+ label.textContent = opts.summaryLabel ?? "Bundle total";
525
529
  text.appendChild(label);
526
530
  if (opts.withItemCount) {
527
531
  label.appendChild(box("span", "", { "data-item-count": "" }));
@@ -552,6 +556,7 @@ function buildWidgetShell(typeClass, opts) {
552
556
  summary.appendChild(prices);
553
557
  root.appendChild(summary);
554
558
  const cta = box("button", "lb-bundle-cta", {
559
+ "data-cta-text": opts.ctaText,
555
560
  type: "button",
556
561
  "data-add-bundle": ""
557
562
  });
@@ -570,7 +575,9 @@ function buildWidgetShell(typeClass, opts) {
570
575
  );
571
576
  return { root, products, cta };
572
577
  }
573
- function buildVolumeTiers(tiers, groupLabel) {
578
+ function buildVolumeTiers(tiers, groupLabel, opts = {}) {
579
+ const showCompare = opts.showCompare !== false;
580
+ const showPriceEach = opts.showPriceEach !== false;
574
581
  const group = box("div", "lb-volume__tiers lb-edge-fade", {
575
582
  role: "radiogroup",
576
583
  "aria-label": groupLabel,
@@ -595,8 +602,17 @@ function buildVolumeTiers(tiers, groupLabel) {
595
602
  label.textContent = tier.label;
596
603
  info.appendChild(label);
597
604
  const price = box("span", "lb-volume__tier-price");
598
- price.appendChild(box("span", "lb-volume__tier-compare"));
599
- price.appendChild(box("span", "", { "data-tier-price-each": "" }));
605
+ if (showCompare && (tier.percent > 0 || tier.amountCents > 0)) {
606
+ price.appendChild(box("span", "lb-volume__tier-compare"));
607
+ }
608
+ if (showPriceEach) {
609
+ price.appendChild(box("span", "", { "data-tier-price-each": "" }));
610
+ if (opts.unitLabel) {
611
+ const unit = box("span", "lb-volume__tier-unit");
612
+ unit.textContent = opts.unitLabel;
613
+ price.appendChild(unit);
614
+ }
615
+ }
600
616
  info.appendChild(price);
601
617
  el2.appendChild(info);
602
618
  if (tier.badgeLabel || tier.savingsLabel) {
@@ -705,7 +721,10 @@ var DEFAULT_STRINGS = {
705
721
  required: "Required",
706
722
  soldOut: "Sold out",
707
723
  outOfStock: "Out of stock",
708
- itemsOutOfStock: "__COUNT__ items out of stock",
724
+ itemsOutOfStock: {
725
+ one: "__COUNT__ item out of stock",
726
+ other: "__COUNT__ items out of stock"
727
+ },
709
728
  quantity: "Quantity",
710
729
  increaseQuantity: "Increase quantity",
711
730
  decreaseQuantity: "Decrease quantity",
@@ -725,11 +744,26 @@ var DEFAULT_STRINGS = {
725
744
  stepsCompleted: "__COUNT__ of __TOTAL__ steps completed",
726
745
  each: " each",
727
746
  buyQty: "Buy __COUNT__",
747
+ itemCount: {
748
+ one: "__COUNT__ item",
749
+ other: "__COUNT__ items"
750
+ },
728
751
  needsSpots: {
729
752
  one: "Needs __COUNT__ spot",
730
753
  other: "Needs __COUNT__ spots"
731
754
  }
732
755
  };
756
+ function formatItemsOutOfStock(t, count) {
757
+ const raw = t.itemsOutOfStock;
758
+ let tpl;
759
+ if (raw && typeof raw === "object") {
760
+ const form = new Intl.PluralRules(t.locale || "en").select(count);
761
+ tpl = raw[form] || raw.other;
762
+ } else {
763
+ tpl = raw;
764
+ }
765
+ return (tpl || "__COUNT__ items out of stock").split("__COUNT__").join(String(count));
766
+ }
733
767
 
734
768
  // ../render/src/dropdown/bind.ts
735
769
  import { dropdown } from "@lime-bundles/core";
@@ -1186,7 +1220,7 @@ function renderFixedBundle(container, bundle, outOfStockBehavior, onAddToCart, o
1186
1220
  shell.cta.disabled = true;
1187
1221
  const label = shell.cta.querySelector("[data-cta-label]");
1188
1222
  if (label) {
1189
- label.textContent = (t.itemsOutOfStock || "__COUNT__ items out of stock").split("__COUNT__").join(String(oosProducts.length));
1223
+ label.textContent = formatItemsOutOfStock(t, oosProducts.length);
1190
1224
  }
1191
1225
  } else {
1192
1226
  shell.cta.addEventListener("click", () => {
@@ -1244,15 +1278,38 @@ function formatSubtitle(remaining, discountType, discountValue, t) {
1244
1278
 
1245
1279
  // ../render/src/picker/rules.ts
1246
1280
  var DEFAULT_RULE = { min: 1, max: 99 };
1247
- function ruleFor(productId, rulesMap) {
1281
+ function ruleFor(productId, rulesMap, variantId) {
1282
+ if (variantId != null) {
1283
+ const vRule = rulesMap["gid://shopify/ProductVariant/" + variantId];
1284
+ if (vRule && typeof vRule.min === "number" && typeof vRule.max === "number") {
1285
+ return vRule;
1286
+ }
1287
+ }
1248
1288
  const rule = rulesMap["gid://shopify/Product/" + productId];
1249
1289
  if (!rule || typeof rule.min !== "number" || typeof rule.max !== "number") {
1250
1290
  return DEFAULT_RULE;
1251
1291
  }
1252
1292
  return rule;
1253
1293
  }
1254
- function defaultPickQuantity(productId, rulesMap) {
1255
- return ruleFor(productId, rulesMap).min;
1294
+ function variantRuleFor(variantId, rulesMap) {
1295
+ if (variantId == null) return void 0;
1296
+ const rule = rulesMap["gid://shopify/ProductVariant/" + variantId];
1297
+ if (!rule || typeof rule.min !== "number" || typeof rule.max !== "number") {
1298
+ return void 0;
1299
+ }
1300
+ return rule;
1301
+ }
1302
+ function mergeRuleMaps(productRules, variantRules) {
1303
+ const products = productRules ?? {};
1304
+ if (!variantRules) return products;
1305
+ const keys = Object.keys(variantRules);
1306
+ if (keys.length === 0) return products;
1307
+ const merged = { ...products };
1308
+ for (const key of keys) merged[key] = variantRules[key];
1309
+ return merged;
1310
+ }
1311
+ function defaultPickQuantity(productId, rulesMap, variantId) {
1312
+ return ruleFor(productId, rulesMap, variantId).min;
1256
1313
  }
1257
1314
  function totalUnits(items) {
1258
1315
  let qty = 0;
@@ -1272,11 +1329,9 @@ function committedQtyForVariant(items, variantId) {
1272
1329
  function isRowAddable(rule, remainingSpots) {
1273
1330
  return rule.min <= remainingSpots;
1274
1331
  }
1275
- function stepperCeiling(rule, remainingSpots, stock, ownQty = 0, productOtherUnits = 0) {
1276
- const ceiling = Math.min(
1277
- rule.max - productOtherUnits,
1278
- ownQty + remainingSpots
1279
- );
1332
+ function stepperCeiling(rule, remainingSpots, stock, ownQty = 0, productOtherUnits = 0, variantMax) {
1333
+ let ceiling = Math.min(rule.max - productOtherUnits, ownQty + remainingSpots);
1334
+ if (typeof variantMax === "number") ceiling = Math.min(ceiling, variantMax);
1280
1335
  return typeof stock === "number" ? Math.min(ceiling, stock) : ceiling;
1281
1336
  }
1282
1337
 
@@ -1355,7 +1410,8 @@ function buildPickerRow(p, rowIndex, ctx) {
1355
1410
  }
1356
1411
  infoDiv.appendChild(titleEl);
1357
1412
  let currentVariant = firstAvailable ?? (p.variants.length > 0 ? p.variants[0] : null);
1358
- const modalQty = currentVariant ? pRule.min : 1;
1413
+ const ruleForVariant = (v) => ruleFor(p.id, rulesMap, v ? v.id : null);
1414
+ const modalQty = currentVariant ? ruleForVariant(currentVariant).min : 1;
1359
1415
  const priceEl = document.createElement("p");
1360
1416
  priceEl.className = "lb-mix-match__modal-product-price";
1361
1417
  priceEl.setAttribute("data-row-price", "");
@@ -1391,8 +1447,8 @@ function buildPickerRow(p, rowIndex, ctx) {
1391
1447
  infoDiv.appendChild(unitPriceEl);
1392
1448
  let qtyGroup = null;
1393
1449
  let qtyValueEl = null;
1394
- let current = pRule.min;
1395
- let effectiveMax = pRule.max;
1450
+ let current = ruleForVariant(currentVariant).min;
1451
+ let effectiveMax = ruleForVariant(currentVariant).max;
1396
1452
  let paint = null;
1397
1453
  let setBoundsForVariant = null;
1398
1454
  if (p.available && ctx.showQtySelector) {
@@ -1414,7 +1470,7 @@ function buildPickerRow(p, rowIndex, ctx) {
1414
1470
  qtyValueEl.className = "lb-mix-match__qty-stepper-value";
1415
1471
  qtyValueEl.setAttribute("data-row-qty", "");
1416
1472
  qtyValueEl.setAttribute("aria-live", "polite");
1417
- qtyValueEl.textContent = String(pRule.min);
1473
+ qtyValueEl.textContent = String(ruleForVariant(currentVariant).min);
1418
1474
  const qtyPlusBtn = document.createElement("button");
1419
1475
  qtyPlusBtn.type = "button";
1420
1476
  qtyPlusBtn.className = "lb-mix-match__qty-stepper-button lb-mix-match__qty-stepper-button--plus";
@@ -1431,7 +1487,7 @@ function buildPickerRow(p, rowIndex, ctx) {
1431
1487
  paint = () => {
1432
1488
  valueEl.textContent = String(current);
1433
1489
  valueEl.setAttribute("data-qty", String(current));
1434
- qtyMinusBtn.disabled = stepperDisabled || current <= pRule.min;
1490
+ qtyMinusBtn.disabled = stepperDisabled || current <= ruleForVariant(currentVariant).min;
1435
1491
  qtyPlusBtn.disabled = stepperDisabled || current >= effectiveMax;
1436
1492
  };
1437
1493
  setBoundsForVariant = (variant, alreadyInBundle = 0) => {
@@ -1442,18 +1498,21 @@ function buildPickerRow(p, rowIndex, ctx) {
1442
1498
  const stock = variant ? variant.inventoryQuantity : null;
1443
1499
  const othersInBundle = Math.max(0, alreadyInBundle - ownQty);
1444
1500
  const stockCap = typeof stock === "number" ? Math.max(0, stock - othersInBundle) : void 0;
1501
+ const vMax = variantRuleFor(variantId, rulesMap)?.max;
1502
+ const rMin = ruleForVariant(variant).min;
1445
1503
  effectiveMax = swapUnits > 0 ? typeof stockCap === "number" ? Math.min(swapUnits, stockCap) : swapUnits : stepperCeiling(
1446
1504
  pRule,
1447
1505
  capacity.remainingSpots(),
1448
1506
  stockCap,
1449
1507
  ownQty,
1450
- otherUnits
1508
+ otherUnits,
1509
+ vMax
1451
1510
  );
1452
- if (effectiveMax < pRule.min) {
1453
- current = pRule.min;
1511
+ if (effectiveMax < rMin) {
1512
+ current = rMin;
1454
1513
  } else {
1455
1514
  if (current > effectiveMax) current = effectiveMax;
1456
- if (current < pRule.min) current = pRule.min;
1515
+ if (current < rMin) current = rMin;
1457
1516
  }
1458
1517
  paint();
1459
1518
  };
@@ -1464,7 +1523,7 @@ function buildPickerRow(p, rowIndex, ctx) {
1464
1523
  }
1465
1524
  };
1466
1525
  qtyMinusBtn.addEventListener("click", () => {
1467
- if (current > pRule.min) current -= 1;
1526
+ if (current > ruleForVariant(currentVariant).min) current -= 1;
1468
1527
  paint();
1469
1528
  commitQtyToBundle();
1470
1529
  });
@@ -1534,7 +1593,7 @@ function buildPickerRow(p, rowIndex, ctx) {
1534
1593
  }
1535
1594
  currentVariant = v;
1536
1595
  row.setAttribute("data-selected-variant-id", String(v.id));
1537
- const resolvedQty = pRule.min;
1596
+ const resolvedQty = ruleForVariant(v).min;
1538
1597
  priceSaleEl.textContent = formatMoney(v.price * resolvedQty);
1539
1598
  paintRowCompare(priceCompareEl, v, resolvedQty);
1540
1599
  if (v.unitPrice) {
@@ -1636,17 +1695,19 @@ function buildPickerRow(p, rowIndex, ctx) {
1636
1695
  if (setBoundsForVariant) {
1637
1696
  const committed = capacity.ownQty(variant.id);
1638
1697
  if (committed > 0) current = committed;
1639
- else if (lastVariantId !== variant.id) current = pRule.min;
1698
+ else if (lastVariantId !== variant.id)
1699
+ current = ruleForVariant(variant).min;
1640
1700
  lastVariantId = variant.id;
1641
1701
  setBoundsForVariant(variant, already);
1642
1702
  }
1643
1703
  const rowSwapUnits = capacity.swapUnits(p.id, variant.id);
1644
- const inSwapState = rowSwapUnits >= pRule.min;
1704
+ const vRule = ruleForVariant(variant);
1705
+ const inSwapState = rowSwapUnits >= vRule.min;
1645
1706
  const remaining = capacity.remainingSpots();
1646
- const needsMoreSpots = !inBundle && !inSwapState && !isRowAddable(pRule, remaining);
1707
+ const needsMoreSpots = !inBundle && !inSwapState && !isRowAddable(vRule, remaining);
1647
1708
  if (needsSpotsEl) {
1648
1709
  if (needsMoreSpots && remaining > 0) {
1649
- needsSpotsEl.textContent = formatNeedsSpots(pRule.min, t);
1710
+ needsSpotsEl.textContent = formatNeedsSpots(vRule.min, t);
1650
1711
  needsSpotsEl.hidden = false;
1651
1712
  } else {
1652
1713
  needsSpotsEl.hidden = true;
@@ -1683,11 +1744,11 @@ function buildPickerRow(p, rowIndex, ctx) {
1683
1744
  addBtn.classList.remove("lb-mix-match__modal-add--required");
1684
1745
  row.classList.remove("lb-mix-match__modal-product--in-bundle");
1685
1746
  addedBadge.hidden = true;
1686
- const noStock = typeof stock === "number" ? Math.max(0, stock - already) < pRule.min : false;
1747
+ const noStock = typeof stock === "number" ? Math.max(0, stock - already) < vRule.min : false;
1687
1748
  if (inSwapState) {
1688
1749
  addBtn.disabled = noStock;
1689
1750
  } else {
1690
- const productMaxed = pRule.max - capacity.productUnitsElsewhere(p.id, variant.id) < pRule.min;
1751
+ const productMaxed = pRule.max - capacity.productUnitsElsewhere(p.id, variant.id) < vRule.min;
1691
1752
  addBtn.disabled = noStock || productMaxed || needsMoreSpots || capacity.isFull();
1692
1753
  }
1693
1754
  }
@@ -1839,7 +1900,7 @@ function createPickerModal(deps) {
1839
1900
  rowQtyEl.getAttribute("data-qty") || rowQtyEl.textContent || "",
1840
1901
  10
1841
1902
  ) || 1
1842
- ) : defaultPickQuantity(prod.id, deps.rulesMap);
1903
+ ) : defaultPickQuantity(prod.id, deps.rulesMap, selectedVariant.id);
1843
1904
  const newItem = {
1844
1905
  productId: prod.id,
1845
1906
  variantId: selectedVariant.id,
@@ -2048,6 +2109,7 @@ function productUnitsInOtherSlots(items, productId, excludeVariantId) {
2048
2109
  return qty;
2049
2110
  }
2050
2111
  function canRemovePick(items, item, rulesMap) {
2112
+ if (variantRuleFor(item.variantId, rulesMap)?.required) return false;
2051
2113
  const rule = ruleFor(item.productId, rulesMap);
2052
2114
  if (!rule.required) return true;
2053
2115
  let unitsElsewhere = 0;
@@ -2312,8 +2374,17 @@ function seedSelection(eligibleProducts, rulesMap, requiredQty, courtesy) {
2312
2374
  });
2313
2375
  const required = [];
2314
2376
  for (const p of eligibleProducts) {
2377
+ if (!p.available || !p.variants) continue;
2378
+ let seededVariant = false;
2379
+ for (const v3 of p.variants) {
2380
+ const vRule = variantRuleFor(v3.id, rulesMap);
2381
+ if (!vRule?.required || !v3.available) continue;
2382
+ required.push(pick(p, v3, Math.max(1, vRule.min)));
2383
+ seededVariant = true;
2384
+ }
2385
+ if (seededVariant) continue;
2315
2386
  const rule = ruleFor(p.id, rulesMap);
2316
- if (!rule.required || !p.available || !p.variants) continue;
2387
+ if (!rule.required) continue;
2317
2388
  const v2 = firstAvailable(p);
2318
2389
  if (!v2) continue;
2319
2390
  required.push(pick(p, v2, Math.max(1, rule.min)));
@@ -2514,17 +2585,17 @@ function buildPickerModal(opts) {
2514
2585
  }
2515
2586
 
2516
2587
  // src/renderers/mix-match.ts
2517
- function renderMixMatchBundle(container, bundle, outOfStockBehavior, onAddToCart, onCleanup) {
2588
+ function renderMixMatchBundle(container, bundle, outOfStockBehavior, onAddToCart, onCleanup, currentProductHandle) {
2518
2589
  const wc = bundle.widgetConfig;
2519
2590
  const t = DEFAULT_STRINGS;
2520
2591
  const requiredQty = bundle.minQuantity || 1;
2521
- const rulesMap = bundle.productRules ?? {};
2592
+ const rulesMap = mergeRuleMaps(bundle.productRules, bundle.variantRules);
2522
2593
  const adapted = adaptProducts(bundle);
2523
- const eligibleProducts = adapted.map((p) => ({
2594
+ const eligibleProducts = adapted.map((p, i) => ({
2524
2595
  id: Number(p.productId),
2525
2596
  title: p.title ?? "",
2526
2597
  url: p.url ?? null,
2527
- type: "",
2598
+ type: bundle.products[i]?.productType ?? "",
2528
2599
  featuredImage: p.featuredImage ?? null,
2529
2600
  available: p.variants.some((v) => v.available),
2530
2601
  optionNames: p.optionNames,
@@ -2569,16 +2640,21 @@ function renderMixMatchBundle(container, bundle, outOfStockBehavior, onAddToCart
2569
2640
  const overlay = buildPickerModal({
2570
2641
  bundleGid: bundle.id,
2571
2642
  domId: bundle.id.replace(/\D/g, ""),
2572
- showSearch: true,
2573
- showTypeFilters: false,
2643
+ showSearch: wc.showSearch !== false,
2644
+ showTypeFilters: wc.mixMatchShowTypeFilters !== false,
2574
2645
  segmentCount: requiredQty,
2575
2646
  wizard: false
2576
2647
  });
2577
2648
  shell.root.appendChild(overlay);
2649
+ const currentIdx = currentProductHandle ? bundle.products.findIndex((p) => p.handle === currentProductHandle) : -1;
2578
2650
  const selectedItems = seedSelection(
2579
2651
  eligibleProducts,
2580
2652
  rulesMap,
2581
- requiredQty
2653
+ requiredQty,
2654
+ {
2655
+ enabled: !!currentProductHandle,
2656
+ currentProductId: currentIdx !== -1 ? eligibleProducts[currentIdx]?.id ?? null : null
2657
+ }
2582
2658
  );
2583
2659
  const rowCapacity = {
2584
2660
  remainingSpots: () => remainingUnits(selectedItems, requiredQty),
@@ -2615,7 +2691,13 @@ function renderMixMatchBundle(container, bundle, outOfStockBehavior, onAddToCart
2615
2691
  shell.root,
2616
2692
  selectedItems,
2617
2693
  requiredQty,
2618
- bundle,
2694
+ // The shared updater reads the theme data-block shape: discount fields
2695
+ // flat, not nested under discountConfig. Passing the bundle itself left
2696
+ // `discountType` undefined, so the summary never painted.
2697
+ {
2698
+ discountType: bundle.discountConfig.discountType,
2699
+ discountValue: bundle.discountConfig.discountValue
2700
+ },
2619
2701
  (total, type, value) => calculateDiscount(total, type, value),
2620
2702
  updatePricing2(formatMoney)
2621
2703
  );
@@ -2628,7 +2710,7 @@ function renderMixMatchBundle(container, bundle, outOfStockBehavior, onAddToCart
2628
2710
  rulesMap,
2629
2711
  eligibleProducts,
2630
2712
  requiredQty,
2631
- showQtySelector: true,
2713
+ showQtySelector: wc.mixMatchShowQuantitySelector !== false,
2632
2714
  items: selectedItems,
2633
2715
  formatMoney,
2634
2716
  rowCapacity,
@@ -2639,7 +2721,9 @@ function renderMixMatchBundle(container, bundle, outOfStockBehavior, onAddToCart
2639
2721
  onSelectionChanged: updateAll,
2640
2722
  addSelection: (item) => {
2641
2723
  if (totalUnits(selectedItems) + (item.quantity || 1) > requiredQty) return;
2642
- const headroom = ruleFor(item.productId, rulesMap).max - productUnitsInOtherSlots(selectedItems, item.productId, item.variantId);
2724
+ const vMax = variantRuleFor(item.variantId, rulesMap)?.max;
2725
+ let headroom = ruleFor(item.productId, rulesMap).max - productUnitsInOtherSlots(selectedItems, item.productId, item.variantId);
2726
+ if (typeof vMax === "number") headroom = Math.min(headroom, vMax);
2643
2727
  if ((item.quantity || 1) > headroom) return;
2644
2728
  selectedItems.push(item);
2645
2729
  updateAll();
@@ -2689,6 +2773,13 @@ function renderMixMatchBundle(container, bundle, outOfStockBehavior, onAddToCart
2689
2773
  onCleanup?.(bindDropdowns(shell.root));
2690
2774
  }
2691
2775
 
2776
+ // src/renderers/volume.ts
2777
+ import {
2778
+ bestValueTierIndex,
2779
+ resolveDefaultTierIndex,
2780
+ resolvePopularTierIndex
2781
+ } from "@lime-bundles/core";
2782
+
2692
2783
  // ../render/src/volume/pricing.ts
2693
2784
  function calcTierPrice(basePrice, discountType, tierEl) {
2694
2785
  if (discountType === "fixed_amount") {
@@ -2717,6 +2808,23 @@ function formatItemCount(qty, translations) {
2717
2808
  const tpl = translations.itemCount[form] || translations.itemCount.other || "__COUNT__";
2718
2809
  return " (" + tpl.split("__COUNT__").join(String(qty)) + ")";
2719
2810
  }
2811
+ function updateAllTierPrices(allTiers, basePrice, discountType, formatMoney) {
2812
+ for (let i = 0; i < allTiers.length; i++) {
2813
+ const tierEl = allTiers[i];
2814
+ const priceEachEl = tierEl.querySelector(
2815
+ "[data-tier-price-each]"
2816
+ );
2817
+ if (priceEachEl) {
2818
+ priceEachEl.textContent = formatMoney(
2819
+ calcTierPrice(basePrice, discountType, tierEl)
2820
+ );
2821
+ }
2822
+ const compareEl = tierEl.querySelector(
2823
+ ".lb-volume__tier-compare"
2824
+ );
2825
+ if (compareEl) compareEl.textContent = formatMoney(basePrice);
2826
+ }
2827
+ }
2720
2828
  function selectTier(container, allTiers, basePrice, discountType, index, translations, formatMoney) {
2721
2829
  for (let i = 0; i < allTiers.length; i++) {
2722
2830
  allTiers[i].setAttribute("aria-checked", i === index ? "true" : "false");
@@ -2774,8 +2882,17 @@ function renderVolumeBundle(container, bundle, outOfStockBehavior, onAddToCart)
2774
2882
  const discountType = bundle.discountConfig.discountType;
2775
2883
  const formatMoney = intlFormatMoney(product.priceRange.minVariantPrice.currencyCode);
2776
2884
  const t = DEFAULT_STRINGS;
2777
- const popularIdx = wc.popularBadge?.visible ? Math.floor((bundle.volumeTiers.length - 1) / 2) : -1;
2778
- const defaultIdx = 0;
2885
+ const bestIdx = bestValueTierIndex(bundle.volumeTiers, discountType);
2886
+ const defaultIdx = resolveDefaultTierIndex(
2887
+ wc.defaultTier,
2888
+ bundle.volumeTiers.length,
2889
+ bestIdx
2890
+ );
2891
+ const popularIdx = resolvePopularTierIndex(
2892
+ wc.popularBadge?.tierIndex,
2893
+ wc.popularBadge?.visible !== false,
2894
+ bestIdx
2895
+ );
2779
2896
  const tiers = bundle.volumeTiers.map((tier, i) => {
2780
2897
  const percent = discountType === "fixed_amount" ? 0 : Math.round(tier.percentage ?? 0);
2781
2898
  const amountCents = discountType === "fixed_amount" ? Math.round((tier.amount ?? 0) * 100) : 0;
@@ -2796,16 +2913,21 @@ function renderVolumeBundle(container, bundle, outOfStockBehavior, onAddToCart)
2796
2913
  title: bundle.title,
2797
2914
  subtitle: bundle.description,
2798
2915
  summaryLabel: "Total",
2799
- withItemCount: true,
2916
+ withItemCount: wc.pricing?.showItemCount !== false,
2800
2917
  totalPriceSlot: true,
2801
2918
  showSavingsBar: wc.savingsBar?.visible !== false,
2802
2919
  showComparePrice: wc.pricing?.showCompareAtPrice !== false,
2803
2920
  ctaText: wc.cta?.ctaText || t.addToCart || "Add to cart",
2804
2921
  endsAt: wc.countdown?.showCountdown === false ? null : bundle.endsAt
2805
2922
  });
2806
- const group = buildVolumeTiers(tiers, "Select quantity");
2923
+ const group = buildVolumeTiers(tiers, "Select quantity", {
2924
+ showCompare: wc.pricing?.showComparePrice !== false,
2925
+ showPriceEach: wc.pricing?.showPerUnitPrice !== false,
2926
+ unitLabel: (t.each || "each").trim()
2927
+ });
2807
2928
  shell.products.replaceWith(group);
2808
2929
  const tierEls = group.querySelectorAll("[data-tier-index]");
2930
+ updateAllTierPrices(tierEls, basePrice, discountType, formatMoney);
2809
2931
  let selectedIndex = defaultIdx;
2810
2932
  const apply = () => selectTier(shell.root, tierEls, basePrice, discountType, selectedIndex, t, formatMoney);
2811
2933
  group.addEventListener("click", (e) => {
@@ -2955,6 +3077,13 @@ function buildBogoCartItems(sides, percent, buyQty, getQty) {
2955
3077
  }
2956
3078
 
2957
3079
  // src/renderers/bogo.ts
3080
+ function buildBogoPlus() {
3081
+ const plus = document.createElement("div");
3082
+ plus.className = "lb-bogo__plus";
3083
+ plus.setAttribute("aria-hidden", "true");
3084
+ 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
+ return plus;
3086
+ }
2958
3087
  function renderBogoBundle(container, bundle, outOfStockBehavior, onAddToCart, onCleanup) {
2959
3088
  const wc = bundle.widgetConfig;
2960
3089
  const buyProduct = bundle.products.find((p) => p.id === bundle.buyProductId);
@@ -3003,6 +3132,7 @@ function renderBogoBundle(container, bundle, outOfStockBehavior, onAddToCart, on
3003
3132
  const badgeText = percent >= 100 ? "Free" : percent + "% off";
3004
3133
  sides.forEach((side, i) => {
3005
3134
  const isGet = i === 1;
3135
+ if (isGet) shell.products.appendChild(buildBogoPlus());
3006
3136
  const isOos = !side.variants.some((v) => v.available);
3007
3137
  const row = buildRowSkeleton(side, t, {
3008
3138
  isOos,
@@ -3031,7 +3161,7 @@ function renderBogoBundle(container, bundle, outOfStockBehavior, onAddToCart, on
3031
3161
  shell.cta.disabled = true;
3032
3162
  const label = shell.cta.querySelector("[data-cta-label]");
3033
3163
  if (label) {
3034
- label.textContent = (t.itemsOutOfStock || "__COUNT__ items out of stock").split("__COUNT__").join(String(oosCount));
3164
+ label.textContent = formatItemsOutOfStock(t, oosCount);
3035
3165
  }
3036
3166
  } else {
3037
3167
  shell.cta.addEventListener("click", () => {
@@ -3102,6 +3232,7 @@ function allSatisfied(selections, steps) {
3102
3232
  return true;
3103
3233
  }
3104
3234
  function canRemovePick2(selections, item, rulesMap) {
3235
+ if (variantRuleFor(item.variantId, rulesMap)?.required) return false;
3105
3236
  const rule = ruleFor(item.productId, rulesMap);
3106
3237
  if (!rule.required) return true;
3107
3238
  let unitsElsewhere = 0;
@@ -3454,7 +3585,7 @@ function createWizard(deps) {
3454
3585
  rowQtyEl.getAttribute("data-qty") || rowQtyEl.textContent || "",
3455
3586
  10
3456
3587
  ) || 1
3457
- ) : defaultPickQuantity(prod.id, deps.rulesMap);
3588
+ ) : defaultPickQuantity(prod.id, deps.rulesMap, selectedVariant.id);
3458
3589
  const newItem = {
3459
3590
  productId: prod.id,
3460
3591
  variantId: selectedVariant.id,
@@ -3674,7 +3805,7 @@ function createWizard(deps) {
3674
3805
  function renderMultiStepBundle(container, bundle, outOfStockBehavior, onAddToCart, onCleanup) {
3675
3806
  const wc = bundle.widgetConfig;
3676
3807
  const t = DEFAULT_STRINGS;
3677
- const rulesMap = bundle.productRules ?? {};
3808
+ const rulesMap = mergeRuleMaps(bundle.productRules, bundle.variantRules);
3678
3809
  const steps = bundle.steps ?? [];
3679
3810
  if (!steps.length) return;
3680
3811
  const quantities = {
@@ -3756,8 +3887,8 @@ function renderMultiStepBundle(container, bundle, outOfStockBehavior, onAddToCar
3756
3887
  const overlay = buildPickerModal({
3757
3888
  bundleGid: bundle.id,
3758
3889
  domId: bundle.id.replace(/\D/g, ""),
3759
- showSearch: true,
3760
- showTypeFilters: true,
3890
+ showSearch: wc.showSearch !== false,
3891
+ showTypeFilters: wc.mixMatchShowTypeFilters !== false,
3761
3892
  segmentCount: null,
3762
3893
  wizard: true
3763
3894
  });
@@ -3804,8 +3935,8 @@ function renderMultiStepBundle(container, bundle, outOfStockBehavior, onAddToCar
3804
3935
  selections,
3805
3936
  t,
3806
3937
  rulesMap,
3807
- showQtySelector: true,
3808
- showTypeFilters: true,
3938
+ showQtySelector: wc.mixMatchShowQuantitySelector !== false,
3939
+ showTypeFilters: wc.mixMatchShowTypeFilters !== false,
3809
3940
  formatMoney,
3810
3941
  dropdown: void 0,
3811
3942
  portalTarget: null,
@@ -4194,7 +4325,10 @@ a.lb-bundle-product-name:focus-visible {
4194
4325
  toggle: present in admin \u2192 shown; absent \u2192 hidden. Styled as muted
4195
4326
  secondary text beneath the price row so it doesn't compete visually. */
4196
4327
  .lb-bundle-product-unit-price {
4197
- display: block;
4328
+ /* Fallback is the "on" branch; Liquid sets 'none' when the merchant disables
4329
+ productList.showUnitPrice. The [hidden] rule below still wins for rows
4330
+ whose variant has no unit price at all. */
4331
+ display: var(--lb-product-unit-price-display, block);
4198
4332
  font-size: 12px;
4199
4333
  line-height: 16px;
4200
4334
  color: color-mix(in srgb, var(--lb-text) 60%, transparent);
@@ -4373,6 +4507,10 @@ a.lb-bundle-product-name:focus-visible {
4373
4507
  cards). Quiet, theme-inherited; the fixed widget uses .lb-bundle-qty-chip in
4374
4508
  the card's right slot instead. */
4375
4509
  .lb-bundle-qty-inline {
4510
+ /* Fallback is the "on" branch; Liquid sets 'none' when the merchant disables
4511
+ productList.showQuantity. The fixed widget's chip carries its own var
4512
+ because it is a flex box, not inline text. */
4513
+ display: var(--lb-product-qty-inline-display, inline);
4376
4514
  font-size: 13px;
4377
4515
  font-weight: 400;
4378
4516
  color: color-mix(in srgb, var(--lb-text) 55%, transparent);
@@ -4605,6 +4743,10 @@ a.lb-bundle-product-name:focus-visible {
4605
4743
  .lb-bundle-thumbnail[style*="--lb-row-thumb-ratio"] {
4606
4744
  aspect-ratio: var(--lb-row-thumb-ratio);
4607
4745
  }
4746
+ /* This selector outranks the \`.lb-bundle-thumbnail\` rule above, so Liquid must
4747
+ emit the inline var for the "original" ratio and nothing else. Emitting it
4748
+ unconditionally silently replaced every merchant ratio with the image's own,
4749
+ which looks like the setting being ignored. */
4608
4750
  `;
4609
4751
  var BUNDLE_FIXED_CSS = `/* Lime Bundles \u2014 Fixed bundle styles */
4610
4752
 
@@ -4654,7 +4796,10 @@ var BUNDLE_FIXED_CSS = `/* Lime Bundles \u2014 Fixed bundle styles */
4654
4796
  min-width: 34px;
4655
4797
  height: 28px;
4656
4798
  padding: 0 8px;
4657
- display: inline-flex;
4799
+ /* Fallback is the "on" branch; Liquid sets 'none' when the merchant disables
4800
+ productList.showQuantity. Separate from --lb-product-qty-inline-display
4801
+ because this chip is a flex box and the other counts are inline text. */
4802
+ display: var(--lb-product-qty-chip-display, inline-flex);
4658
4803
  align-items: center;
4659
4804
  justify-content: center;
4660
4805
  border-radius: var(--lb-radius-sm);
@@ -6497,6 +6642,13 @@ var LimeBundleElement = class extends HTMLElement {
6497
6642
  buyer = void 0;
6498
6643
  shadow;
6499
6644
  bundles = [];
6645
+ /**
6646
+ * The product handle this embed is standing on, when one can be resolved
6647
+ * (attribute → meta tag → /products/<handle> path). The mix-and-match
6648
+ * courtesy seed uses it to pre-add the current product the way the theme
6649
+ * widget does; null on pages with no product context.
6650
+ */
6651
+ currentProductHandle = null;
6500
6652
  abortController = null;
6501
6653
  impressionCleanups = [];
6502
6654
  /**
@@ -6616,11 +6768,12 @@ var LimeBundleElement = class extends HTMLElement {
6616
6768
  try {
6617
6769
  let bundlePromise;
6618
6770
  let singleBundleMode = false;
6771
+ this.currentProductHandle = resolveProductHandle(this.productHandleAttr);
6619
6772
  if (this.bundleGid) {
6620
6773
  singleBundleMode = true;
6621
6774
  bundlePromise = this.fetchSingleBundle(client, controller.signal);
6622
6775
  } else {
6623
- const handle = resolveProductHandle(this.productHandleAttr);
6776
+ const handle = this.currentProductHandle;
6624
6777
  if (!handle) {
6625
6778
  this.teardownImpressions();
6626
6779
  this.renderError(
@@ -6696,7 +6849,7 @@ var LimeBundleElement = class extends HTMLElement {
6696
6849
  const parsed = parseMetaobjectBundle(ref.id, ref.fields);
6697
6850
  if (parsed && !this.isMarketHidden(parsed)) bundles.push(parsed);
6698
6851
  }
6699
- this.bundles = bundles;
6852
+ this.bundles = resolveAbTests(bundles, getVisitorId());
6700
6853
  }
6701
6854
  /**
6702
6855
  * Dispatch add-to-cart with a cancelable event, then — unless a listener
@@ -6834,6 +6987,7 @@ var LimeBundleElement = class extends HTMLElement {
6834
6987
  container.setAttribute("aria-label", bundle.title);
6835
6988
  container.setAttribute("data-bundle-type", bundle.bundleType);
6836
6989
  container.setAttribute("data-bundle-gid", bundle.id);
6990
+ if (bundle.endsAt) container.setAttribute("data-ends-at", bundle.endsAt);
6837
6991
  applyWidgetConfigVars(container, bundle.widgetConfig);
6838
6992
  this.renderCleanups.push(trackInputMode(container));
6839
6993
  const dispatch = (lines) => this.handleAddToCart(bundle, lines);
@@ -6854,7 +7008,8 @@ var LimeBundleElement = class extends HTMLElement {
6854
7008
  bundle,
6855
7009
  this.outOfStockBehavior,
6856
7010
  dispatch,
6857
- registerCleanup
7011
+ registerCleanup,
7012
+ this.currentProductHandle
6858
7013
  );
6859
7014
  break;
6860
7015
  case "volume":