@lime-bundles/widget 2.5.0 → 2.6.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
@@ -534,9 +536,18 @@ function renderFixedBundle(container, bundle, onAddToCart, onCleanup) {
534
536
  const list = el("div", "lb-fixed__products");
535
537
  const rowHandles = [];
536
538
  rows.forEach((rowState) => {
537
- const handle = renderProductRow(rowState, currency, qtyFor, () => {
538
- updatePricing();
539
- });
539
+ const handle = renderProductRow(
540
+ rowState,
541
+ currency,
542
+ qtyFor,
543
+ {
544
+ enabled: wc.showLowStockBadge,
545
+ threshold: wc.lowStockThreshold
546
+ },
547
+ () => {
548
+ updatePricing();
549
+ }
550
+ );
540
551
  rowHandles.push(handle);
541
552
  list.appendChild(handle.el);
542
553
  });
@@ -592,7 +603,9 @@ function renderFixedBundle(container, bundle, onAddToCart, onCleanup) {
592
603
  function buildRowState(bundle, product, productIndex) {
593
604
  const selectedVariantIds = bundle.selectedVariantIds?.[productIndex] ?? null;
594
605
  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;
606
+ const firstInStock = merchantScoped.find(
607
+ (v) => isVariantFulfillable(v, resolveBundleQty(bundle, product.id, v.id))
608
+ ) ?? null;
596
609
  const eligibleVariants = merchantScoped;
597
610
  const isOos = !firstInStock;
598
611
  const selected = firstInStock ?? merchantScoped[0] ?? null;
@@ -656,7 +669,7 @@ function deriveHeaderBadge(bundle, totalCents, saleCents, currency) {
656
669
  }
657
670
  return `-${formatCents(savings, currency)}`;
658
671
  }
659
- function renderProductRow(state, currency, qtyFor, onVariantChange) {
672
+ function renderProductRow(state, currency, qtyFor, lowStock, onVariantChange) {
660
673
  const rowEl = el(
661
674
  "div",
662
675
  state.isOos ? "lb-bundle-product-row lb-bundle-product-row--oos" : "lb-bundle-product-row",
@@ -703,6 +716,12 @@ function renderProductRow(state, currency, qtyFor, onVariantChange) {
703
716
  oosLabel.textContent = "Out of stock";
704
717
  info.appendChild(oosLabel);
705
718
  } else if (state.selected) {
719
+ const isSinglePinnedVariant = state.eligibleVariants.length === 1 && state.product.variants.nodes.length > 1;
720
+ if (isSinglePinnedVariant) {
721
+ const badge = el("span", "lb-bundle-variant-badge");
722
+ badge.textContent = state.eligibleVariants[0].title;
723
+ info.appendChild(badge);
724
+ }
706
725
  const prices = el("span", "lb-bundle-product-prices");
707
726
  const compare = el("span", "lb-bundle-product-compare-price", {
708
727
  "data-product-compare-price": ""
@@ -718,6 +737,11 @@ function renderProductRow(state, currency, qtyFor, onVariantChange) {
718
737
  });
719
738
  unitPriceEl.setAttribute("hidden", "");
720
739
  info.appendChild(unitPriceEl);
740
+ const lowStockEl = el("span", "lb-bundle-low-stock-badge", {
741
+ "data-low-stock-badge": ""
742
+ });
743
+ lowStockEl.setAttribute("hidden", "");
744
+ info.appendChild(lowStockEl);
721
745
  const applyVariantToRow = (variant) => {
722
746
  const unit = parseCents(variant.price.amount);
723
747
  priceEl.textContent = formatCents(unit, currency);
@@ -752,6 +776,18 @@ function renderProductRow(state, currency, qtyFor, onVariantChange) {
752
776
  });
753
777
  thumbImg.alt = nextImage.altText ?? state.product.title;
754
778
  }
779
+ const required = qtyFor(state.product.id, variant.id);
780
+ if (shouldShowLowStockBadge(
781
+ variant,
782
+ required,
783
+ lowStock.threshold,
784
+ lowStock.enabled
785
+ )) {
786
+ lowStockEl.textContent = `Only ${variant.quantityAvailable} left`;
787
+ lowStockEl.removeAttribute("hidden");
788
+ } else {
789
+ lowStockEl.setAttribute("hidden", "");
790
+ }
755
791
  };
756
792
  applyVariantToRow(state.selected);
757
793
  if (state.eligibleVariants.length > 1) {
@@ -770,7 +806,7 @@ function renderProductRow(state, currency, qtyFor, onVariantChange) {
770
806
  });
771
807
  };
772
808
  const isValueAvailable = (optionIndex, value, selected) => state.eligibleVariants.some((v) => {
773
- if (!v.availableForSale) return false;
809
+ if (!isVariantFulfillable(v, qtyFor(state.product.id, v.id))) return false;
774
810
  if (v.selectedOptions[optionIndex]?.value !== value) return false;
775
811
  return v.selectedOptions.every(
776
812
  (o, i) => i === optionIndex || o.value === selected[i]
@@ -834,10 +870,6 @@ function renderProductRow(state, currency, qtyFor, onVariantChange) {
834
870
  if (state.selected) {
835
871
  recomputeDisabled(state.selected.selectedOptions.map((o) => o.value));
836
872
  }
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
873
  }
842
874
  }
843
875
  rowEl.appendChild(info);
@@ -922,7 +954,7 @@ function computeSale(totalCents, discount, rows) {
922
954
  }
923
955
 
924
956
  // src/renderers/mix-match.ts
925
- import { resolveBundleQty as resolveBundleQty2 } from "@lime-bundles/core";
957
+ import { resolveBundleQty as resolveBundleQty2, isVariantFulfillable as isVariantFulfillable2, shouldShowLowStockBadge as shouldShowLowStockBadge2 } from "@lime-bundles/core";
926
958
  var PLACEHOLDER_THUMB_SVG2 = `
927
959
  <svg class="lb-bundle-placeholder-icon" viewBox="0 0 28 28" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
928
960
  <rect x="4" y="4" width="20" height="20" rx="3"></rect>
@@ -1412,7 +1444,9 @@ function renderModal(bundle, eligible, currency, handlers) {
1412
1444
  list.innerHTML = "";
1413
1445
  eligible.forEach((ep) => {
1414
1446
  const availableVariants = ep.variants;
1415
- const firstAvailVariant = ep.variants.find((v) => v.availableForSale) ?? ep.firstAvailableVariant ?? ep.variants[0];
1447
+ const firstAvailVariant = ep.variants.find(
1448
+ (v) => isVariantFulfillable2(v, resolveBundleQty2(bundle, ep.product.id, v.id))
1449
+ ) ?? ep.firstAvailableVariant ?? ep.variants[0];
1416
1450
  if (!firstAvailVariant) return;
1417
1451
  let currentVariant = firstAvailVariant;
1418
1452
  const productEl = el(
@@ -1421,7 +1455,9 @@ function renderModal(bundle, eligible, currency, handlers) {
1421
1455
  { "data-product-id": ep.product.id.replace(/^.*\//, "") }
1422
1456
  );
1423
1457
  const thumb = el("div", "lb-mix-match__modal-product-thumb");
1424
- const initialThumbVariant = ep.variants.find((v) => v.availableForSale) ?? ep.variants[0] ?? null;
1458
+ const initialThumbVariant = ep.variants.find(
1459
+ (v) => isVariantFulfillable2(v, resolveBundleQty2(bundle, ep.product.id, v.id))
1460
+ ) ?? ep.variants[0] ?? null;
1425
1461
  const initialThumbImage = initialThumbVariant?.image ?? ep.product.featuredImage ?? null;
1426
1462
  let thumbImg = null;
1427
1463
  if (initialThumbImage) {
@@ -1470,6 +1506,26 @@ function renderModal(bundle, eligible, currency, handlers) {
1470
1506
  unitPrice.hidden = true;
1471
1507
  }
1472
1508
  info.appendChild(unitPrice);
1509
+ const lowStockBadge = el("span", "lb-bundle-low-stock-badge", {
1510
+ "data-low-stock-badge": ""
1511
+ });
1512
+ lowStockBadge.hidden = true;
1513
+ info.appendChild(lowStockBadge);
1514
+ const refreshLowStockBadge = (variant) => {
1515
+ const required = resolveBundleQty2(bundle, ep.product.id, variant.id);
1516
+ if (shouldShowLowStockBadge2(
1517
+ variant,
1518
+ required,
1519
+ bundle.widgetConfig.lowStockThreshold,
1520
+ bundle.widgetConfig.showLowStockBadge
1521
+ )) {
1522
+ lowStockBadge.textContent = `Only ${variant.quantityAvailable} left`;
1523
+ lowStockBadge.hidden = false;
1524
+ } else {
1525
+ lowStockBadge.hidden = true;
1526
+ }
1527
+ };
1528
+ refreshLowStockBadge(currentVariant);
1473
1529
  const row = {
1474
1530
  el: productEl,
1475
1531
  product: ep.product,
@@ -1493,7 +1549,7 @@ function renderModal(bundle, eligible, currency, handlers) {
1493
1549
  });
1494
1550
  };
1495
1551
  const isValueAvailable = (optionIndex, value, selected) => availableVariants.some((v) => {
1496
- if (!v.availableForSale) return false;
1552
+ if (!isVariantFulfillable2(v, resolveBundleQty2(bundle, ep.product.id, v.id))) return false;
1497
1553
  if (v.selectedOptions[optionIndex]?.value !== value) return false;
1498
1554
  return v.selectedOptions.every(
1499
1555
  (o, i) => i === optionIndex || o.value === selected[i]
@@ -1549,6 +1605,7 @@ function renderModal(bundle, eligible, currency, handlers) {
1549
1605
  thumbImg.alt = nextImage.altText ?? ep.product.title;
1550
1606
  }
1551
1607
  recomputeDisabled(next.selectedOptions.map((o) => o.value));
1608
+ refreshLowStockBadge(currentVariant);
1552
1609
  rowUpdateCount();
1553
1610
  };
1554
1611
  const groupsContainer = el("div", "lb-bundle-variant-option-groups");
@@ -1684,7 +1741,9 @@ function buildEligibleProducts(bundle, oosBehavior) {
1684
1741
  for (const product of bundle.products) {
1685
1742
  if (seen.has(product.id)) continue;
1686
1743
  seen.add(product.id);
1687
- const available = product.variants.nodes.filter((v) => v.availableForSale);
1744
+ const available = product.variants.nodes.filter(
1745
+ (v) => isVariantFulfillable2(v, resolveBundleQty2(bundle, product.id, v.id))
1746
+ );
1688
1747
  const isOos = available.length === 0;
1689
1748
  if (isOos && oosBehavior === "hide") continue;
1690
1749
  result.push({
@@ -1717,10 +1776,14 @@ function sanitizeId(gid) {
1717
1776
  }
1718
1777
 
1719
1778
  // src/renderers/volume.ts
1779
+ import { isVariantFulfillable as isVariantFulfillable3, shouldShowLowStockBadge as shouldShowLowStockBadge3 } from "@lime-bundles/core";
1720
1780
  function renderVolumeBundle(container, bundle, onAddToCart, onCleanup) {
1721
1781
  const wc = bundle.widgetConfig;
1722
1782
  const product = bundle.products[0];
1723
- const variant = product?.variants.nodes.find((v) => v.availableForSale);
1783
+ const minTierQty = bundle.volumeTiers[0]?.minQuantity ?? 1;
1784
+ const variant = product?.variants.nodes.find(
1785
+ (v) => isVariantFulfillable3(v, minTierQty)
1786
+ );
1724
1787
  if (!variant && wc.outOfStockBehavior === "hide") return;
1725
1788
  const basePriceCents = variant ? parseCents(variant.price.amount) : 0;
1726
1789
  const currency = variant?.price.currencyCode ?? "USD";
@@ -1819,6 +1882,18 @@ function renderVolumeBundle(container, bundle, onAddToCart, onCleanup) {
1819
1882
  }
1820
1883
  ]);
1821
1884
  });
1885
+ if (variant && shouldShowLowStockBadge3(
1886
+ variant,
1887
+ minTierQty,
1888
+ wc.lowStockThreshold,
1889
+ wc.showLowStockBadge
1890
+ )) {
1891
+ const lowStock = el("span", "lb-bundle-low-stock-badge", {
1892
+ "data-low-stock-badge": ""
1893
+ });
1894
+ lowStock.textContent = `Only ${variant.quantityAvailable} left`;
1895
+ root.appendChild(lowStock);
1896
+ }
1822
1897
  root.appendChild(cta);
1823
1898
  root.appendChild(
1824
1899
  el("p", "lb-bundle-error", { "data-error": "", "aria-live": "polite" })
@@ -1972,7 +2047,10 @@ function renderSavingsBar3(resolved, selectedIndex, currency) {
1972
2047
  }
1973
2048
  function renderCta2(bundle, onClick) {
1974
2049
  const product = bundle.products[0];
1975
- const isAvailable = product?.variants.nodes.some((v) => v.availableForSale);
2050
+ const minTierQty = bundle.volumeTiers[0]?.minQuantity ?? 1;
2051
+ const isAvailable = product?.variants.nodes.some(
2052
+ (v) => isVariantFulfillable3(v, minTierQty)
2053
+ );
1976
2054
  const label = isAvailable ? bundle.widgetConfig.cta.ctaText || "Add to cart" : "Sold out";
1977
2055
  const button = buildCtaButton(label);
1978
2056
  if (!isAvailable) button.disabled = true;
@@ -2349,15 +2427,19 @@ var BUNDLE_BASE_CSS = `/* Lime Bundles \u2014 shared base styles for all bundle
2349
2427
  display: none;
2350
2428
  }
2351
2429
 
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;
2430
+ /* Read-only variant text shown below the product title when only one
2431
+ variant is in scope (single-variant product, or merchant pinned a
2432
+ single variant). Same visual treatment as the mix-match filled
2433
+ slot's variant text \u2014 see .lb-mix-match__filled-variant in
2434
+ bundle-mix-match.css. Both share this rule via the comma selector
2435
+ so the storefront UX stays consistent across bundle types. */
2436
+ .lb-bundle-variant-badge,
2437
+ .lb-mix-match__slot--filled .lb-mix-match__filled-variant {
2438
+ display: block;
2357
2439
  font-size: 12px;
2358
- line-height: 16px;
2440
+ line-height: 20px;
2441
+ margin-top: 2px;
2359
2442
  color: color-mix(in srgb, var(--lb-text) 60%, transparent);
2360
- margin-top: 8px;
2361
2443
  }
2362
2444
 
2363
2445
  /* Per-option variant pickers. Each option's label + select sit inside a
@@ -2401,10 +2483,10 @@ var BUNDLE_BASE_CSS = `/* Lime Bundles \u2014 shared base styles for all bundle
2401
2483
  click-to-focus doesn't leave a keyboard-style ring. The modal overlay
2402
2484
  gets its own selector because the Liquid path reparents it to body,
2403
2485
  outside the widget root. */
2404
- .using-mouse .lb-bundle-widget :focus,
2405
- .using-mouse .lb-bundle-widget :focus-visible,
2406
- .using-mouse .lb-mix-match__modal-overlay :focus,
2407
- .using-mouse .lb-mix-match__modal-overlay :focus-visible {
2486
+ .using-mouse .lb-bundle-widget :focus:not([aria-checked="true"]),
2487
+ .using-mouse .lb-bundle-widget :focus-visible:not([aria-checked="true"]),
2488
+ .using-mouse .lb-mix-match__modal-overlay :focus:not([aria-checked="true"]),
2489
+ .using-mouse .lb-mix-match__modal-overlay :focus-visible:not([aria-checked="true"]) {
2408
2490
  outline: none;
2409
2491
  outline-offset: 0;
2410
2492
  box-shadow: none;
@@ -2631,6 +2713,24 @@ var BUNDLE_BASE_CSS = `/* Lime Bundles \u2014 shared base styles for all bundle
2631
2713
  margin-left: auto;
2632
2714
  }
2633
2715
 
2716
+ /* "Only X left" low-stock badge \u2014 appears alongside product/variant
2717
+ info when ProductVariant.quantityAvailable falls at or below
2718
+ wc.lowStockThreshold. Hidden when quantityAvailable is unknown
2719
+ (Storefront token without read_product_inventory). */
2720
+ .lb-bundle-low-stock-badge {
2721
+ display: inline-flex;
2722
+ align-items: center;
2723
+ gap: 4px;
2724
+ padding: 2px 8px;
2725
+ font-size: 11px;
2726
+ font-weight: 600;
2727
+ line-height: 1.4;
2728
+ color: var(--lb-low-stock-text);
2729
+ background-color: var(--lb-low-stock-bg);
2730
+ border-radius: 4px;
2731
+ white-space: nowrap;
2732
+ }
2733
+
2634
2734
  /* A/B test: hide save badge until JS swaps the label (prevents flash of default) */
2635
2735
  .lb-ab-pending {
2636
2736
  visibility: hidden;
@@ -2854,12 +2954,9 @@ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
2854
2954
  text-decoration: underline;
2855
2955
  }
2856
2956
 
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
- }
2957
+ /* .lb-mix-match__filled-variant \u2014 styled jointly with
2958
+ .lb-bundle-variant-badge above to keep the variant text consistent
2959
+ across mix-match and fixed bundle widgets. */
2863
2960
 
2864
2961
  .lb-mix-match__filled-price {
2865
2962
  /* --lb-product-price-display fallback is the "on" branch; Liquid sets 'none' when merchant disables. */
@@ -3283,10 +3380,21 @@ var BUNDLE_VOLUME_CSS = `/* Lime Bundles \u2014 Volume / Quantity Breaks styles
3283
3380
  }
3284
3381
 
3285
3382
  .lb-volume__tier:hover {
3286
- border-color: color-mix(in srgb, var(--lb-tier-border-color) 50%, black);
3383
+ border-color: var(--lb-tier-selected-border-color);
3384
+ }
3385
+
3386
+ /* Suppress the UA default focus outline so a freshly-clicked selected
3387
+ tier doesn't briefly show the 1px focus ring on top of (or in place of)
3388
+ the custom selected-state outline below. Keyboard focus is still
3389
+ indicated by the :focus-visible rule. */
3390
+ .lb-volume__tier:focus {
3391
+ outline: none;
3287
3392
  }
3288
3393
 
3289
- .lb-volume__tier:focus-visible {
3394
+ /* Keyboard focus indicator \u2014 explicitly excludes the selected tier so
3395
+ the selected-state outline rule below has full ownership of the
3396
+ outline property when both states apply at once. */
3397
+ .lb-volume__tier:focus-visible:not([aria-checked="true"]) {
3290
3398
  outline: 2px solid var(--lb-primary-color);
3291
3399
  outline-offset: 2px;
3292
3400
  }