@lime-bundles/widget 4.0.0 → 4.1.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.cjs CHANGED
@@ -1249,15 +1249,38 @@ function formatSubtitle(remaining, discountType, discountValue, t) {
1249
1249
 
1250
1250
  // ../render/src/picker/rules.ts
1251
1251
  var DEFAULT_RULE = { min: 1, max: 99 };
1252
- function ruleFor(productId, rulesMap) {
1252
+ function ruleFor(productId, rulesMap, variantId) {
1253
+ if (variantId != null) {
1254
+ const vRule = rulesMap["gid://shopify/ProductVariant/" + variantId];
1255
+ if (vRule && typeof vRule.min === "number" && typeof vRule.max === "number") {
1256
+ return vRule;
1257
+ }
1258
+ }
1253
1259
  const rule = rulesMap["gid://shopify/Product/" + productId];
1254
1260
  if (!rule || typeof rule.min !== "number" || typeof rule.max !== "number") {
1255
1261
  return DEFAULT_RULE;
1256
1262
  }
1257
1263
  return rule;
1258
1264
  }
1259
- function defaultPickQuantity(productId, rulesMap) {
1260
- return ruleFor(productId, rulesMap).min;
1265
+ function variantRuleFor(variantId, rulesMap) {
1266
+ if (variantId == null) return void 0;
1267
+ const rule = rulesMap["gid://shopify/ProductVariant/" + variantId];
1268
+ if (!rule || typeof rule.min !== "number" || typeof rule.max !== "number") {
1269
+ return void 0;
1270
+ }
1271
+ return rule;
1272
+ }
1273
+ function mergeRuleMaps(productRules, variantRules) {
1274
+ const products = productRules ?? {};
1275
+ if (!variantRules) return products;
1276
+ const keys = Object.keys(variantRules);
1277
+ if (keys.length === 0) return products;
1278
+ const merged = { ...products };
1279
+ for (const key of keys) merged[key] = variantRules[key];
1280
+ return merged;
1281
+ }
1282
+ function defaultPickQuantity(productId, rulesMap, variantId) {
1283
+ return ruleFor(productId, rulesMap, variantId).min;
1261
1284
  }
1262
1285
  function totalUnits(items) {
1263
1286
  let qty = 0;
@@ -1277,11 +1300,9 @@ function committedQtyForVariant(items, variantId) {
1277
1300
  function isRowAddable(rule, remainingSpots) {
1278
1301
  return rule.min <= remainingSpots;
1279
1302
  }
1280
- function stepperCeiling(rule, remainingSpots, stock, ownQty = 0, productOtherUnits = 0) {
1281
- const ceiling = Math.min(
1282
- rule.max - productOtherUnits,
1283
- ownQty + remainingSpots
1284
- );
1303
+ function stepperCeiling(rule, remainingSpots, stock, ownQty = 0, productOtherUnits = 0, variantMax) {
1304
+ let ceiling = Math.min(rule.max - productOtherUnits, ownQty + remainingSpots);
1305
+ if (typeof variantMax === "number") ceiling = Math.min(ceiling, variantMax);
1285
1306
  return typeof stock === "number" ? Math.min(ceiling, stock) : ceiling;
1286
1307
  }
1287
1308
 
@@ -1360,7 +1381,8 @@ function buildPickerRow(p, rowIndex, ctx) {
1360
1381
  }
1361
1382
  infoDiv.appendChild(titleEl);
1362
1383
  let currentVariant = firstAvailable ?? (p.variants.length > 0 ? p.variants[0] : null);
1363
- const modalQty = currentVariant ? pRule.min : 1;
1384
+ const ruleForVariant = (v) => ruleFor(p.id, rulesMap, v ? v.id : null);
1385
+ const modalQty = currentVariant ? ruleForVariant(currentVariant).min : 1;
1364
1386
  const priceEl = document.createElement("p");
1365
1387
  priceEl.className = "lb-mix-match__modal-product-price";
1366
1388
  priceEl.setAttribute("data-row-price", "");
@@ -1396,8 +1418,8 @@ function buildPickerRow(p, rowIndex, ctx) {
1396
1418
  infoDiv.appendChild(unitPriceEl);
1397
1419
  let qtyGroup = null;
1398
1420
  let qtyValueEl = null;
1399
- let current = pRule.min;
1400
- let effectiveMax = pRule.max;
1421
+ let current = ruleForVariant(currentVariant).min;
1422
+ let effectiveMax = ruleForVariant(currentVariant).max;
1401
1423
  let paint = null;
1402
1424
  let setBoundsForVariant = null;
1403
1425
  if (p.available && ctx.showQtySelector) {
@@ -1419,7 +1441,7 @@ function buildPickerRow(p, rowIndex, ctx) {
1419
1441
  qtyValueEl.className = "lb-mix-match__qty-stepper-value";
1420
1442
  qtyValueEl.setAttribute("data-row-qty", "");
1421
1443
  qtyValueEl.setAttribute("aria-live", "polite");
1422
- qtyValueEl.textContent = String(pRule.min);
1444
+ qtyValueEl.textContent = String(ruleForVariant(currentVariant).min);
1423
1445
  const qtyPlusBtn = document.createElement("button");
1424
1446
  qtyPlusBtn.type = "button";
1425
1447
  qtyPlusBtn.className = "lb-mix-match__qty-stepper-button lb-mix-match__qty-stepper-button--plus";
@@ -1436,7 +1458,7 @@ function buildPickerRow(p, rowIndex, ctx) {
1436
1458
  paint = () => {
1437
1459
  valueEl.textContent = String(current);
1438
1460
  valueEl.setAttribute("data-qty", String(current));
1439
- qtyMinusBtn.disabled = stepperDisabled || current <= pRule.min;
1461
+ qtyMinusBtn.disabled = stepperDisabled || current <= ruleForVariant(currentVariant).min;
1440
1462
  qtyPlusBtn.disabled = stepperDisabled || current >= effectiveMax;
1441
1463
  };
1442
1464
  setBoundsForVariant = (variant, alreadyInBundle = 0) => {
@@ -1447,18 +1469,21 @@ function buildPickerRow(p, rowIndex, ctx) {
1447
1469
  const stock = variant ? variant.inventoryQuantity : null;
1448
1470
  const othersInBundle = Math.max(0, alreadyInBundle - ownQty);
1449
1471
  const stockCap = typeof stock === "number" ? Math.max(0, stock - othersInBundle) : void 0;
1472
+ const vMax = variantRuleFor(variantId, rulesMap)?.max;
1473
+ const rMin = ruleForVariant(variant).min;
1450
1474
  effectiveMax = swapUnits > 0 ? typeof stockCap === "number" ? Math.min(swapUnits, stockCap) : swapUnits : stepperCeiling(
1451
1475
  pRule,
1452
1476
  capacity.remainingSpots(),
1453
1477
  stockCap,
1454
1478
  ownQty,
1455
- otherUnits
1479
+ otherUnits,
1480
+ vMax
1456
1481
  );
1457
- if (effectiveMax < pRule.min) {
1458
- current = pRule.min;
1482
+ if (effectiveMax < rMin) {
1483
+ current = rMin;
1459
1484
  } else {
1460
1485
  if (current > effectiveMax) current = effectiveMax;
1461
- if (current < pRule.min) current = pRule.min;
1486
+ if (current < rMin) current = rMin;
1462
1487
  }
1463
1488
  paint();
1464
1489
  };
@@ -1469,7 +1494,7 @@ function buildPickerRow(p, rowIndex, ctx) {
1469
1494
  }
1470
1495
  };
1471
1496
  qtyMinusBtn.addEventListener("click", () => {
1472
- if (current > pRule.min) current -= 1;
1497
+ if (current > ruleForVariant(currentVariant).min) current -= 1;
1473
1498
  paint();
1474
1499
  commitQtyToBundle();
1475
1500
  });
@@ -1539,7 +1564,7 @@ function buildPickerRow(p, rowIndex, ctx) {
1539
1564
  }
1540
1565
  currentVariant = v;
1541
1566
  row.setAttribute("data-selected-variant-id", String(v.id));
1542
- const resolvedQty = pRule.min;
1567
+ const resolvedQty = ruleForVariant(v).min;
1543
1568
  priceSaleEl.textContent = formatMoney(v.price * resolvedQty);
1544
1569
  paintRowCompare(priceCompareEl, v, resolvedQty);
1545
1570
  if (v.unitPrice) {
@@ -1641,17 +1666,19 @@ function buildPickerRow(p, rowIndex, ctx) {
1641
1666
  if (setBoundsForVariant) {
1642
1667
  const committed = capacity.ownQty(variant.id);
1643
1668
  if (committed > 0) current = committed;
1644
- else if (lastVariantId !== variant.id) current = pRule.min;
1669
+ else if (lastVariantId !== variant.id)
1670
+ current = ruleForVariant(variant).min;
1645
1671
  lastVariantId = variant.id;
1646
1672
  setBoundsForVariant(variant, already);
1647
1673
  }
1648
1674
  const rowSwapUnits = capacity.swapUnits(p.id, variant.id);
1649
- const inSwapState = rowSwapUnits >= pRule.min;
1675
+ const vRule = ruleForVariant(variant);
1676
+ const inSwapState = rowSwapUnits >= vRule.min;
1650
1677
  const remaining = capacity.remainingSpots();
1651
- const needsMoreSpots = !inBundle && !inSwapState && !isRowAddable(pRule, remaining);
1678
+ const needsMoreSpots = !inBundle && !inSwapState && !isRowAddable(vRule, remaining);
1652
1679
  if (needsSpotsEl) {
1653
1680
  if (needsMoreSpots && remaining > 0) {
1654
- needsSpotsEl.textContent = formatNeedsSpots(pRule.min, t);
1681
+ needsSpotsEl.textContent = formatNeedsSpots(vRule.min, t);
1655
1682
  needsSpotsEl.hidden = false;
1656
1683
  } else {
1657
1684
  needsSpotsEl.hidden = true;
@@ -1688,11 +1715,11 @@ function buildPickerRow(p, rowIndex, ctx) {
1688
1715
  addBtn.classList.remove("lb-mix-match__modal-add--required");
1689
1716
  row.classList.remove("lb-mix-match__modal-product--in-bundle");
1690
1717
  addedBadge.hidden = true;
1691
- const noStock = typeof stock === "number" ? Math.max(0, stock - already) < pRule.min : false;
1718
+ const noStock = typeof stock === "number" ? Math.max(0, stock - already) < vRule.min : false;
1692
1719
  if (inSwapState) {
1693
1720
  addBtn.disabled = noStock;
1694
1721
  } else {
1695
- const productMaxed = pRule.max - capacity.productUnitsElsewhere(p.id, variant.id) < pRule.min;
1722
+ const productMaxed = pRule.max - capacity.productUnitsElsewhere(p.id, variant.id) < vRule.min;
1696
1723
  addBtn.disabled = noStock || productMaxed || needsMoreSpots || capacity.isFull();
1697
1724
  }
1698
1725
  }
@@ -1844,7 +1871,7 @@ function createPickerModal(deps) {
1844
1871
  rowQtyEl.getAttribute("data-qty") || rowQtyEl.textContent || "",
1845
1872
  10
1846
1873
  ) || 1
1847
- ) : defaultPickQuantity(prod.id, deps.rulesMap);
1874
+ ) : defaultPickQuantity(prod.id, deps.rulesMap, selectedVariant.id);
1848
1875
  const newItem = {
1849
1876
  productId: prod.id,
1850
1877
  variantId: selectedVariant.id,
@@ -2053,6 +2080,7 @@ function productUnitsInOtherSlots(items, productId, excludeVariantId) {
2053
2080
  return qty;
2054
2081
  }
2055
2082
  function canRemovePick(items, item, rulesMap) {
2083
+ if (variantRuleFor(item.variantId, rulesMap)?.required) return false;
2056
2084
  const rule = ruleFor(item.productId, rulesMap);
2057
2085
  if (!rule.required) return true;
2058
2086
  let unitsElsewhere = 0;
@@ -2317,8 +2345,17 @@ function seedSelection(eligibleProducts, rulesMap, requiredQty, courtesy) {
2317
2345
  });
2318
2346
  const required = [];
2319
2347
  for (const p of eligibleProducts) {
2348
+ if (!p.available || !p.variants) continue;
2349
+ let seededVariant = false;
2350
+ for (const v3 of p.variants) {
2351
+ const vRule = variantRuleFor(v3.id, rulesMap);
2352
+ if (!vRule?.required || !v3.available) continue;
2353
+ required.push(pick(p, v3, Math.max(1, vRule.min)));
2354
+ seededVariant = true;
2355
+ }
2356
+ if (seededVariant) continue;
2320
2357
  const rule = ruleFor(p.id, rulesMap);
2321
- if (!rule.required || !p.available || !p.variants) continue;
2358
+ if (!rule.required) continue;
2322
2359
  const v2 = firstAvailable(p);
2323
2360
  if (!v2) continue;
2324
2361
  required.push(pick(p, v2, Math.max(1, rule.min)));
@@ -2523,7 +2560,7 @@ function renderMixMatchBundle(container, bundle, outOfStockBehavior, onAddToCart
2523
2560
  const wc = bundle.widgetConfig;
2524
2561
  const t = DEFAULT_STRINGS;
2525
2562
  const requiredQty = bundle.minQuantity || 1;
2526
- const rulesMap = bundle.productRules ?? {};
2563
+ const rulesMap = mergeRuleMaps(bundle.productRules, bundle.variantRules);
2527
2564
  const adapted = adaptProducts(bundle);
2528
2565
  const eligibleProducts = adapted.map((p) => ({
2529
2566
  id: Number(p.productId),
@@ -2644,7 +2681,9 @@ function renderMixMatchBundle(container, bundle, outOfStockBehavior, onAddToCart
2644
2681
  onSelectionChanged: updateAll,
2645
2682
  addSelection: (item) => {
2646
2683
  if (totalUnits(selectedItems) + (item.quantity || 1) > requiredQty) return;
2647
- const headroom = ruleFor(item.productId, rulesMap).max - productUnitsInOtherSlots(selectedItems, item.productId, item.variantId);
2684
+ const vMax = variantRuleFor(item.variantId, rulesMap)?.max;
2685
+ let headroom = ruleFor(item.productId, rulesMap).max - productUnitsInOtherSlots(selectedItems, item.productId, item.variantId);
2686
+ if (typeof vMax === "number") headroom = Math.min(headroom, vMax);
2648
2687
  if ((item.quantity || 1) > headroom) return;
2649
2688
  selectedItems.push(item);
2650
2689
  updateAll();
@@ -3104,6 +3143,7 @@ function allSatisfied(selections, steps) {
3104
3143
  return true;
3105
3144
  }
3106
3145
  function canRemovePick2(selections, item, rulesMap) {
3146
+ if (variantRuleFor(item.variantId, rulesMap)?.required) return false;
3107
3147
  const rule = ruleFor(item.productId, rulesMap);
3108
3148
  if (!rule.required) return true;
3109
3149
  let unitsElsewhere = 0;
@@ -3456,7 +3496,7 @@ function createWizard(deps) {
3456
3496
  rowQtyEl.getAttribute("data-qty") || rowQtyEl.textContent || "",
3457
3497
  10
3458
3498
  ) || 1
3459
- ) : defaultPickQuantity(prod.id, deps.rulesMap);
3499
+ ) : defaultPickQuantity(prod.id, deps.rulesMap, selectedVariant.id);
3460
3500
  const newItem = {
3461
3501
  productId: prod.id,
3462
3502
  variantId: selectedVariant.id,
@@ -3676,7 +3716,7 @@ function createWizard(deps) {
3676
3716
  function renderMultiStepBundle(container, bundle, outOfStockBehavior, onAddToCart, onCleanup) {
3677
3717
  const wc = bundle.widgetConfig;
3678
3718
  const t = DEFAULT_STRINGS;
3679
- const rulesMap = bundle.productRules ?? {};
3719
+ const rulesMap = mergeRuleMaps(bundle.productRules, bundle.variantRules);
3680
3720
  const steps = bundle.steps ?? [];
3681
3721
  if (!steps.length) return;
3682
3722
  const quantities = {
@@ -4196,7 +4236,10 @@ a.lb-bundle-product-name:focus-visible {
4196
4236
  toggle: present in admin \u2192 shown; absent \u2192 hidden. Styled as muted
4197
4237
  secondary text beneath the price row so it doesn't compete visually. */
4198
4238
  .lb-bundle-product-unit-price {
4199
- display: block;
4239
+ /* Fallback is the "on" branch; Liquid sets 'none' when the merchant disables
4240
+ productList.showUnitPrice. The [hidden] rule below still wins for rows
4241
+ whose variant has no unit price at all. */
4242
+ display: var(--lb-product-unit-price-display, block);
4200
4243
  font-size: 12px;
4201
4244
  line-height: 16px;
4202
4245
  color: color-mix(in srgb, var(--lb-text) 60%, transparent);
@@ -4375,6 +4418,10 @@ a.lb-bundle-product-name:focus-visible {
4375
4418
  cards). Quiet, theme-inherited; the fixed widget uses .lb-bundle-qty-chip in
4376
4419
  the card's right slot instead. */
4377
4420
  .lb-bundle-qty-inline {
4421
+ /* Fallback is the "on" branch; Liquid sets 'none' when the merchant disables
4422
+ productList.showQuantity. The fixed widget's chip carries its own var
4423
+ because it is a flex box, not inline text. */
4424
+ display: var(--lb-product-qty-inline-display, inline);
4378
4425
  font-size: 13px;
4379
4426
  font-weight: 400;
4380
4427
  color: color-mix(in srgb, var(--lb-text) 55%, transparent);
@@ -4607,6 +4654,10 @@ a.lb-bundle-product-name:focus-visible {
4607
4654
  .lb-bundle-thumbnail[style*="--lb-row-thumb-ratio"] {
4608
4655
  aspect-ratio: var(--lb-row-thumb-ratio);
4609
4656
  }
4657
+ /* This selector outranks the \`.lb-bundle-thumbnail\` rule above, so Liquid must
4658
+ emit the inline var for the "original" ratio and nothing else. Emitting it
4659
+ unconditionally silently replaced every merchant ratio with the image's own,
4660
+ which looks like the setting being ignored. */
4610
4661
  `;
4611
4662
  var BUNDLE_FIXED_CSS = `/* Lime Bundles \u2014 Fixed bundle styles */
4612
4663
 
@@ -4656,7 +4707,10 @@ var BUNDLE_FIXED_CSS = `/* Lime Bundles \u2014 Fixed bundle styles */
4656
4707
  min-width: 34px;
4657
4708
  height: 28px;
4658
4709
  padding: 0 8px;
4659
- display: inline-flex;
4710
+ /* Fallback is the "on" branch; Liquid sets 'none' when the merchant disables
4711
+ productList.showQuantity. Separate from --lb-product-qty-inline-display
4712
+ because this chip is a flex box and the other counts are inline text. */
4713
+ display: var(--lb-product-qty-chip-display, inline-flex);
4660
4714
  align-items: center;
4661
4715
  justify-content: center;
4662
4716
  border-radius: var(--lb-radius-sm);
@@ -6698,7 +6752,7 @@ var LimeBundleElement = class extends HTMLElement {
6698
6752
  const parsed = (0, import_core5.parseMetaobjectBundle)(ref.id, ref.fields);
6699
6753
  if (parsed && !this.isMarketHidden(parsed)) bundles.push(parsed);
6700
6754
  }
6701
- this.bundles = bundles;
6755
+ this.bundles = (0, import_core5.resolveAbTests)(bundles, (0, import_core5.getVisitorId)());
6702
6756
  }
6703
6757
  /**
6704
6758
  * Dispatch add-to-cart with a cancelable event, then — unless a listener
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
@@ -1244,15 +1246,38 @@ function formatSubtitle(remaining, discountType, discountValue, t) {
1244
1246
 
1245
1247
  // ../render/src/picker/rules.ts
1246
1248
  var DEFAULT_RULE = { min: 1, max: 99 };
1247
- function ruleFor(productId, rulesMap) {
1249
+ function ruleFor(productId, rulesMap, variantId) {
1250
+ if (variantId != null) {
1251
+ const vRule = rulesMap["gid://shopify/ProductVariant/" + variantId];
1252
+ if (vRule && typeof vRule.min === "number" && typeof vRule.max === "number") {
1253
+ return vRule;
1254
+ }
1255
+ }
1248
1256
  const rule = rulesMap["gid://shopify/Product/" + productId];
1249
1257
  if (!rule || typeof rule.min !== "number" || typeof rule.max !== "number") {
1250
1258
  return DEFAULT_RULE;
1251
1259
  }
1252
1260
  return rule;
1253
1261
  }
1254
- function defaultPickQuantity(productId, rulesMap) {
1255
- return ruleFor(productId, rulesMap).min;
1262
+ function variantRuleFor(variantId, rulesMap) {
1263
+ if (variantId == null) return void 0;
1264
+ const rule = rulesMap["gid://shopify/ProductVariant/" + variantId];
1265
+ if (!rule || typeof rule.min !== "number" || typeof rule.max !== "number") {
1266
+ return void 0;
1267
+ }
1268
+ return rule;
1269
+ }
1270
+ function mergeRuleMaps(productRules, variantRules) {
1271
+ const products = productRules ?? {};
1272
+ if (!variantRules) return products;
1273
+ const keys = Object.keys(variantRules);
1274
+ if (keys.length === 0) return products;
1275
+ const merged = { ...products };
1276
+ for (const key of keys) merged[key] = variantRules[key];
1277
+ return merged;
1278
+ }
1279
+ function defaultPickQuantity(productId, rulesMap, variantId) {
1280
+ return ruleFor(productId, rulesMap, variantId).min;
1256
1281
  }
1257
1282
  function totalUnits(items) {
1258
1283
  let qty = 0;
@@ -1272,11 +1297,9 @@ function committedQtyForVariant(items, variantId) {
1272
1297
  function isRowAddable(rule, remainingSpots) {
1273
1298
  return rule.min <= remainingSpots;
1274
1299
  }
1275
- function stepperCeiling(rule, remainingSpots, stock, ownQty = 0, productOtherUnits = 0) {
1276
- const ceiling = Math.min(
1277
- rule.max - productOtherUnits,
1278
- ownQty + remainingSpots
1279
- );
1300
+ function stepperCeiling(rule, remainingSpots, stock, ownQty = 0, productOtherUnits = 0, variantMax) {
1301
+ let ceiling = Math.min(rule.max - productOtherUnits, ownQty + remainingSpots);
1302
+ if (typeof variantMax === "number") ceiling = Math.min(ceiling, variantMax);
1280
1303
  return typeof stock === "number" ? Math.min(ceiling, stock) : ceiling;
1281
1304
  }
1282
1305
 
@@ -1355,7 +1378,8 @@ function buildPickerRow(p, rowIndex, ctx) {
1355
1378
  }
1356
1379
  infoDiv.appendChild(titleEl);
1357
1380
  let currentVariant = firstAvailable ?? (p.variants.length > 0 ? p.variants[0] : null);
1358
- const modalQty = currentVariant ? pRule.min : 1;
1381
+ const ruleForVariant = (v) => ruleFor(p.id, rulesMap, v ? v.id : null);
1382
+ const modalQty = currentVariant ? ruleForVariant(currentVariant).min : 1;
1359
1383
  const priceEl = document.createElement("p");
1360
1384
  priceEl.className = "lb-mix-match__modal-product-price";
1361
1385
  priceEl.setAttribute("data-row-price", "");
@@ -1391,8 +1415,8 @@ function buildPickerRow(p, rowIndex, ctx) {
1391
1415
  infoDiv.appendChild(unitPriceEl);
1392
1416
  let qtyGroup = null;
1393
1417
  let qtyValueEl = null;
1394
- let current = pRule.min;
1395
- let effectiveMax = pRule.max;
1418
+ let current = ruleForVariant(currentVariant).min;
1419
+ let effectiveMax = ruleForVariant(currentVariant).max;
1396
1420
  let paint = null;
1397
1421
  let setBoundsForVariant = null;
1398
1422
  if (p.available && ctx.showQtySelector) {
@@ -1414,7 +1438,7 @@ function buildPickerRow(p, rowIndex, ctx) {
1414
1438
  qtyValueEl.className = "lb-mix-match__qty-stepper-value";
1415
1439
  qtyValueEl.setAttribute("data-row-qty", "");
1416
1440
  qtyValueEl.setAttribute("aria-live", "polite");
1417
- qtyValueEl.textContent = String(pRule.min);
1441
+ qtyValueEl.textContent = String(ruleForVariant(currentVariant).min);
1418
1442
  const qtyPlusBtn = document.createElement("button");
1419
1443
  qtyPlusBtn.type = "button";
1420
1444
  qtyPlusBtn.className = "lb-mix-match__qty-stepper-button lb-mix-match__qty-stepper-button--plus";
@@ -1431,7 +1455,7 @@ function buildPickerRow(p, rowIndex, ctx) {
1431
1455
  paint = () => {
1432
1456
  valueEl.textContent = String(current);
1433
1457
  valueEl.setAttribute("data-qty", String(current));
1434
- qtyMinusBtn.disabled = stepperDisabled || current <= pRule.min;
1458
+ qtyMinusBtn.disabled = stepperDisabled || current <= ruleForVariant(currentVariant).min;
1435
1459
  qtyPlusBtn.disabled = stepperDisabled || current >= effectiveMax;
1436
1460
  };
1437
1461
  setBoundsForVariant = (variant, alreadyInBundle = 0) => {
@@ -1442,18 +1466,21 @@ function buildPickerRow(p, rowIndex, ctx) {
1442
1466
  const stock = variant ? variant.inventoryQuantity : null;
1443
1467
  const othersInBundle = Math.max(0, alreadyInBundle - ownQty);
1444
1468
  const stockCap = typeof stock === "number" ? Math.max(0, stock - othersInBundle) : void 0;
1469
+ const vMax = variantRuleFor(variantId, rulesMap)?.max;
1470
+ const rMin = ruleForVariant(variant).min;
1445
1471
  effectiveMax = swapUnits > 0 ? typeof stockCap === "number" ? Math.min(swapUnits, stockCap) : swapUnits : stepperCeiling(
1446
1472
  pRule,
1447
1473
  capacity.remainingSpots(),
1448
1474
  stockCap,
1449
1475
  ownQty,
1450
- otherUnits
1476
+ otherUnits,
1477
+ vMax
1451
1478
  );
1452
- if (effectiveMax < pRule.min) {
1453
- current = pRule.min;
1479
+ if (effectiveMax < rMin) {
1480
+ current = rMin;
1454
1481
  } else {
1455
1482
  if (current > effectiveMax) current = effectiveMax;
1456
- if (current < pRule.min) current = pRule.min;
1483
+ if (current < rMin) current = rMin;
1457
1484
  }
1458
1485
  paint();
1459
1486
  };
@@ -1464,7 +1491,7 @@ function buildPickerRow(p, rowIndex, ctx) {
1464
1491
  }
1465
1492
  };
1466
1493
  qtyMinusBtn.addEventListener("click", () => {
1467
- if (current > pRule.min) current -= 1;
1494
+ if (current > ruleForVariant(currentVariant).min) current -= 1;
1468
1495
  paint();
1469
1496
  commitQtyToBundle();
1470
1497
  });
@@ -1534,7 +1561,7 @@ function buildPickerRow(p, rowIndex, ctx) {
1534
1561
  }
1535
1562
  currentVariant = v;
1536
1563
  row.setAttribute("data-selected-variant-id", String(v.id));
1537
- const resolvedQty = pRule.min;
1564
+ const resolvedQty = ruleForVariant(v).min;
1538
1565
  priceSaleEl.textContent = formatMoney(v.price * resolvedQty);
1539
1566
  paintRowCompare(priceCompareEl, v, resolvedQty);
1540
1567
  if (v.unitPrice) {
@@ -1636,17 +1663,19 @@ function buildPickerRow(p, rowIndex, ctx) {
1636
1663
  if (setBoundsForVariant) {
1637
1664
  const committed = capacity.ownQty(variant.id);
1638
1665
  if (committed > 0) current = committed;
1639
- else if (lastVariantId !== variant.id) current = pRule.min;
1666
+ else if (lastVariantId !== variant.id)
1667
+ current = ruleForVariant(variant).min;
1640
1668
  lastVariantId = variant.id;
1641
1669
  setBoundsForVariant(variant, already);
1642
1670
  }
1643
1671
  const rowSwapUnits = capacity.swapUnits(p.id, variant.id);
1644
- const inSwapState = rowSwapUnits >= pRule.min;
1672
+ const vRule = ruleForVariant(variant);
1673
+ const inSwapState = rowSwapUnits >= vRule.min;
1645
1674
  const remaining = capacity.remainingSpots();
1646
- const needsMoreSpots = !inBundle && !inSwapState && !isRowAddable(pRule, remaining);
1675
+ const needsMoreSpots = !inBundle && !inSwapState && !isRowAddable(vRule, remaining);
1647
1676
  if (needsSpotsEl) {
1648
1677
  if (needsMoreSpots && remaining > 0) {
1649
- needsSpotsEl.textContent = formatNeedsSpots(pRule.min, t);
1678
+ needsSpotsEl.textContent = formatNeedsSpots(vRule.min, t);
1650
1679
  needsSpotsEl.hidden = false;
1651
1680
  } else {
1652
1681
  needsSpotsEl.hidden = true;
@@ -1683,11 +1712,11 @@ function buildPickerRow(p, rowIndex, ctx) {
1683
1712
  addBtn.classList.remove("lb-mix-match__modal-add--required");
1684
1713
  row.classList.remove("lb-mix-match__modal-product--in-bundle");
1685
1714
  addedBadge.hidden = true;
1686
- const noStock = typeof stock === "number" ? Math.max(0, stock - already) < pRule.min : false;
1715
+ const noStock = typeof stock === "number" ? Math.max(0, stock - already) < vRule.min : false;
1687
1716
  if (inSwapState) {
1688
1717
  addBtn.disabled = noStock;
1689
1718
  } else {
1690
- const productMaxed = pRule.max - capacity.productUnitsElsewhere(p.id, variant.id) < pRule.min;
1719
+ const productMaxed = pRule.max - capacity.productUnitsElsewhere(p.id, variant.id) < vRule.min;
1691
1720
  addBtn.disabled = noStock || productMaxed || needsMoreSpots || capacity.isFull();
1692
1721
  }
1693
1722
  }
@@ -1839,7 +1868,7 @@ function createPickerModal(deps) {
1839
1868
  rowQtyEl.getAttribute("data-qty") || rowQtyEl.textContent || "",
1840
1869
  10
1841
1870
  ) || 1
1842
- ) : defaultPickQuantity(prod.id, deps.rulesMap);
1871
+ ) : defaultPickQuantity(prod.id, deps.rulesMap, selectedVariant.id);
1843
1872
  const newItem = {
1844
1873
  productId: prod.id,
1845
1874
  variantId: selectedVariant.id,
@@ -2048,6 +2077,7 @@ function productUnitsInOtherSlots(items, productId, excludeVariantId) {
2048
2077
  return qty;
2049
2078
  }
2050
2079
  function canRemovePick(items, item, rulesMap) {
2080
+ if (variantRuleFor(item.variantId, rulesMap)?.required) return false;
2051
2081
  const rule = ruleFor(item.productId, rulesMap);
2052
2082
  if (!rule.required) return true;
2053
2083
  let unitsElsewhere = 0;
@@ -2312,8 +2342,17 @@ function seedSelection(eligibleProducts, rulesMap, requiredQty, courtesy) {
2312
2342
  });
2313
2343
  const required = [];
2314
2344
  for (const p of eligibleProducts) {
2345
+ if (!p.available || !p.variants) continue;
2346
+ let seededVariant = false;
2347
+ for (const v3 of p.variants) {
2348
+ const vRule = variantRuleFor(v3.id, rulesMap);
2349
+ if (!vRule?.required || !v3.available) continue;
2350
+ required.push(pick(p, v3, Math.max(1, vRule.min)));
2351
+ seededVariant = true;
2352
+ }
2353
+ if (seededVariant) continue;
2315
2354
  const rule = ruleFor(p.id, rulesMap);
2316
- if (!rule.required || !p.available || !p.variants) continue;
2355
+ if (!rule.required) continue;
2317
2356
  const v2 = firstAvailable(p);
2318
2357
  if (!v2) continue;
2319
2358
  required.push(pick(p, v2, Math.max(1, rule.min)));
@@ -2518,7 +2557,7 @@ function renderMixMatchBundle(container, bundle, outOfStockBehavior, onAddToCart
2518
2557
  const wc = bundle.widgetConfig;
2519
2558
  const t = DEFAULT_STRINGS;
2520
2559
  const requiredQty = bundle.minQuantity || 1;
2521
- const rulesMap = bundle.productRules ?? {};
2560
+ const rulesMap = mergeRuleMaps(bundle.productRules, bundle.variantRules);
2522
2561
  const adapted = adaptProducts(bundle);
2523
2562
  const eligibleProducts = adapted.map((p) => ({
2524
2563
  id: Number(p.productId),
@@ -2639,7 +2678,9 @@ function renderMixMatchBundle(container, bundle, outOfStockBehavior, onAddToCart
2639
2678
  onSelectionChanged: updateAll,
2640
2679
  addSelection: (item) => {
2641
2680
  if (totalUnits(selectedItems) + (item.quantity || 1) > requiredQty) return;
2642
- const headroom = ruleFor(item.productId, rulesMap).max - productUnitsInOtherSlots(selectedItems, item.productId, item.variantId);
2681
+ const vMax = variantRuleFor(item.variantId, rulesMap)?.max;
2682
+ let headroom = ruleFor(item.productId, rulesMap).max - productUnitsInOtherSlots(selectedItems, item.productId, item.variantId);
2683
+ if (typeof vMax === "number") headroom = Math.min(headroom, vMax);
2643
2684
  if ((item.quantity || 1) > headroom) return;
2644
2685
  selectedItems.push(item);
2645
2686
  updateAll();
@@ -3102,6 +3143,7 @@ function allSatisfied(selections, steps) {
3102
3143
  return true;
3103
3144
  }
3104
3145
  function canRemovePick2(selections, item, rulesMap) {
3146
+ if (variantRuleFor(item.variantId, rulesMap)?.required) return false;
3105
3147
  const rule = ruleFor(item.productId, rulesMap);
3106
3148
  if (!rule.required) return true;
3107
3149
  let unitsElsewhere = 0;
@@ -3454,7 +3496,7 @@ function createWizard(deps) {
3454
3496
  rowQtyEl.getAttribute("data-qty") || rowQtyEl.textContent || "",
3455
3497
  10
3456
3498
  ) || 1
3457
- ) : defaultPickQuantity(prod.id, deps.rulesMap);
3499
+ ) : defaultPickQuantity(prod.id, deps.rulesMap, selectedVariant.id);
3458
3500
  const newItem = {
3459
3501
  productId: prod.id,
3460
3502
  variantId: selectedVariant.id,
@@ -3674,7 +3716,7 @@ function createWizard(deps) {
3674
3716
  function renderMultiStepBundle(container, bundle, outOfStockBehavior, onAddToCart, onCleanup) {
3675
3717
  const wc = bundle.widgetConfig;
3676
3718
  const t = DEFAULT_STRINGS;
3677
- const rulesMap = bundle.productRules ?? {};
3719
+ const rulesMap = mergeRuleMaps(bundle.productRules, bundle.variantRules);
3678
3720
  const steps = bundle.steps ?? [];
3679
3721
  if (!steps.length) return;
3680
3722
  const quantities = {
@@ -4194,7 +4236,10 @@ a.lb-bundle-product-name:focus-visible {
4194
4236
  toggle: present in admin \u2192 shown; absent \u2192 hidden. Styled as muted
4195
4237
  secondary text beneath the price row so it doesn't compete visually. */
4196
4238
  .lb-bundle-product-unit-price {
4197
- display: block;
4239
+ /* Fallback is the "on" branch; Liquid sets 'none' when the merchant disables
4240
+ productList.showUnitPrice. The [hidden] rule below still wins for rows
4241
+ whose variant has no unit price at all. */
4242
+ display: var(--lb-product-unit-price-display, block);
4198
4243
  font-size: 12px;
4199
4244
  line-height: 16px;
4200
4245
  color: color-mix(in srgb, var(--lb-text) 60%, transparent);
@@ -4373,6 +4418,10 @@ a.lb-bundle-product-name:focus-visible {
4373
4418
  cards). Quiet, theme-inherited; the fixed widget uses .lb-bundle-qty-chip in
4374
4419
  the card's right slot instead. */
4375
4420
  .lb-bundle-qty-inline {
4421
+ /* Fallback is the "on" branch; Liquid sets 'none' when the merchant disables
4422
+ productList.showQuantity. The fixed widget's chip carries its own var
4423
+ because it is a flex box, not inline text. */
4424
+ display: var(--lb-product-qty-inline-display, inline);
4376
4425
  font-size: 13px;
4377
4426
  font-weight: 400;
4378
4427
  color: color-mix(in srgb, var(--lb-text) 55%, transparent);
@@ -4605,6 +4654,10 @@ a.lb-bundle-product-name:focus-visible {
4605
4654
  .lb-bundle-thumbnail[style*="--lb-row-thumb-ratio"] {
4606
4655
  aspect-ratio: var(--lb-row-thumb-ratio);
4607
4656
  }
4657
+ /* This selector outranks the \`.lb-bundle-thumbnail\` rule above, so Liquid must
4658
+ emit the inline var for the "original" ratio and nothing else. Emitting it
4659
+ unconditionally silently replaced every merchant ratio with the image's own,
4660
+ which looks like the setting being ignored. */
4608
4661
  `;
4609
4662
  var BUNDLE_FIXED_CSS = `/* Lime Bundles \u2014 Fixed bundle styles */
4610
4663
 
@@ -4654,7 +4707,10 @@ var BUNDLE_FIXED_CSS = `/* Lime Bundles \u2014 Fixed bundle styles */
4654
4707
  min-width: 34px;
4655
4708
  height: 28px;
4656
4709
  padding: 0 8px;
4657
- display: inline-flex;
4710
+ /* Fallback is the "on" branch; Liquid sets 'none' when the merchant disables
4711
+ productList.showQuantity. Separate from --lb-product-qty-inline-display
4712
+ because this chip is a flex box and the other counts are inline text. */
4713
+ display: var(--lb-product-qty-chip-display, inline-flex);
4658
4714
  align-items: center;
4659
4715
  justify-content: center;
4660
4716
  border-radius: var(--lb-radius-sm);
@@ -6696,7 +6752,7 @@ var LimeBundleElement = class extends HTMLElement {
6696
6752
  const parsed = parseMetaobjectBundle(ref.id, ref.fields);
6697
6753
  if (parsed && !this.isMarketHidden(parsed)) bundles.push(parsed);
6698
6754
  }
6699
- this.bundles = bundles;
6755
+ this.bundles = resolveAbTests(bundles, getVisitorId());
6700
6756
  }
6701
6757
  /**
6702
6758
  * Dispatch add-to-cart with a cancelable event, then — unless a listener