@lime-bundles/react 2.5.0 → 4.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.cjs CHANGED
@@ -349,6 +349,20 @@ var { computePosition, emptyTypeAheadState, handleKey, pushTypeAheadChar } = imp
349
349
  var ITEM_HEIGHT_PX = 32;
350
350
  var LIST_PAD_Y = 8;
351
351
  var MAX_VISIBLE_ITEMS = 8;
352
+ function findScrollableAncestor(el) {
353
+ const win = el.ownerDocument?.defaultView;
354
+ if (!win) return null;
355
+ let cur = el.parentElement;
356
+ while (cur && cur !== el.ownerDocument.body) {
357
+ const style = win.getComputedStyle(cur);
358
+ const overflowY = style.overflowY;
359
+ if (overflowY === "auto" || overflowY === "scroll" || overflowY === "hidden") {
360
+ return cur;
361
+ }
362
+ cur = cur.parentElement;
363
+ }
364
+ return null;
365
+ }
352
366
  function VariantDropdown({
353
367
  options,
354
368
  value,
@@ -398,6 +412,11 @@ function VariantDropdown({
398
412
  if (rect.width === 0) return;
399
413
  const visibleCount = Math.min(options.length || 1, MAX_VISIBLE_ITEMS);
400
414
  const desiredHeight = visibleCount * ITEM_HEIGHT_PX + LIST_PAD_Y;
415
+ const scrollable = findScrollableAncestor(trigger);
416
+ const clip = scrollable ? (() => {
417
+ const r = scrollable.getBoundingClientRect();
418
+ return { top: r.top, bottom: r.bottom };
419
+ })() : void 0;
401
420
  setPosition(
402
421
  computePosition({
403
422
  trigger: {
@@ -407,7 +426,8 @@ function VariantDropdown({
407
426
  width: rect.width
408
427
  },
409
428
  viewportHeight: window.innerHeight,
410
- desiredHeight
429
+ desiredHeight,
430
+ clip
411
431
  })
412
432
  );
413
433
  }, [isOpen, options.length]);
@@ -814,6 +834,9 @@ function FixedProductRow({ bundle, product, currency, onVariantChange }) {
814
834
  var import_react7 = require("react");
815
835
  var import_core9 = require("@lime-bundles/core");
816
836
  var import_jsx_runtime3 = require("react/jsx-runtime");
837
+ function ruleFor(bundle, productId) {
838
+ return bundle.productRules[productId] ?? import_core9.DEFAULT_PRODUCT_RULE;
839
+ }
817
840
  function MixMatchBundle(props) {
818
841
  const {
819
842
  shopDomain,
@@ -857,56 +880,92 @@ function MixMatchBundle(props) {
857
880
  );
858
881
  }
859
882
  }, [result, onError]);
860
- const totalQuantity = Array.from(selections.values()).reduce(
861
- (sum, s) => sum + s.quantity,
862
- 0
883
+ const showStepper = bundle?.widgetConfig.mixMatchShowQuantitySelector !== false;
884
+ const distinctSelectedProducts = (0, import_react7.useMemo)(() => {
885
+ const ids = /* @__PURE__ */ new Set();
886
+ for (const sel of selections.values()) {
887
+ if (sel.quantity > 0) ids.add(sel.productId);
888
+ }
889
+ return ids;
890
+ }, [selections]);
891
+ const totalQuantity = (0, import_react7.useMemo)(
892
+ () => Array.from(selections.values()).reduce((sum, s) => sum + s.quantity, 0),
893
+ [selections]
863
894
  );
864
- const validation = bundle ? (0, import_core9.validateQuantity)(totalQuantity, bundle.minQuantity, bundle.maxQuantity) : { valid: false, totalQuantity: 0, message: null };
865
- const toggleProduct = (0, import_react7.useCallback)(
895
+ const requiredPicks = bundle?.minQuantity ?? 0;
896
+ const meetsMinPicks = distinctSelectedProducts.size >= requiredPicks;
897
+ const valid = !!bundle && meetsMinPicks;
898
+ const remaining = Math.max(0, requiredPicks - distinctSelectedProducts.size);
899
+ const validationMessage = valid ? null : remaining > 0 ? `Pick ${remaining} more product${remaining === 1 ? "" : "s"}` : null;
900
+ const selectVariant = (0, import_react7.useCallback)(
866
901
  (productId, variant) => {
902
+ if (!bundle) return;
903
+ const rule = ruleFor(bundle, productId);
904
+ const maxAddable = (0, import_core9.maxAddableQuantity)(variant, rule.max, 0);
905
+ if (maxAddable < rule.min) return;
867
906
  setSelections((prev) => {
868
907
  const next = new Map(prev);
869
- const key = `${productId}:${variant.id}`;
870
- if (next.has(key)) {
871
- next.delete(key);
872
- } else {
873
- const configuredQty = bundle ? (0, import_core9.resolveBundleQty)(bundle, productId, variant.id) : 1;
874
- next.set(key, {
875
- productId,
876
- variantId: variant.id,
877
- quantity: configuredQty
878
- });
879
- }
908
+ next.set(`${productId}:${variant.id}`, {
909
+ productId,
910
+ variantId: variant.id,
911
+ quantity: rule.min
912
+ });
880
913
  return next;
881
914
  });
882
915
  },
883
916
  [bundle]
884
917
  );
885
- const updateQuantity = (0, import_react7.useCallback)(
886
- (productId, variantId, quantity) => {
918
+ const deselect = (0, import_react7.useCallback)((productId, variantId) => {
919
+ setSelections((prev) => {
920
+ if (!prev.has(`${productId}:${variantId}`)) return prev;
921
+ const next = new Map(prev);
922
+ next.delete(`${productId}:${variantId}`);
923
+ return next;
924
+ });
925
+ }, []);
926
+ const setQuantity = (0, import_react7.useCallback)(
927
+ (productId, variant, quantity) => {
928
+ if (!bundle) return;
929
+ const rule = ruleFor(bundle, productId);
930
+ const maxAddable = (0, import_core9.maxAddableQuantity)(variant, rule.max, 0);
931
+ const key = `${productId}:${variant.id}`;
887
932
  setSelections((prev) => {
888
933
  const next = new Map(prev);
889
- const key = `${productId}:${variantId}`;
890
- if (quantity <= 0) {
934
+ if (quantity < rule.min || maxAddable < rule.min) {
891
935
  next.delete(key);
892
- } else {
893
- next.set(key, { productId, variantId, quantity });
936
+ return next;
894
937
  }
938
+ const clamped = Math.min(quantity, maxAddable);
939
+ next.set(key, {
940
+ productId,
941
+ variantId: variant.id,
942
+ quantity: clamped
943
+ });
895
944
  return next;
896
945
  });
897
946
  },
898
- []
947
+ [bundle]
899
948
  );
900
949
  const handleAddToCart = (0, import_react7.useCallback)(async () => {
901
- if (!bundle || !validation.valid) return;
902
- const lines = Array.from(selections.values()).map((s) => ({
903
- merchandiseId: s.variantId,
904
- quantity: s.quantity,
905
- attributes: [
906
- { key: "_lime_bundle_gid", value: bundle.id },
907
- { key: "_lime_bundle_type", value: bundle.bundleType }
908
- ]
909
- }));
950
+ if (!bundle || !valid) return;
951
+ const byVariant = /* @__PURE__ */ new Map();
952
+ for (const sel of selections.values()) {
953
+ if (sel.quantity <= 0) continue;
954
+ byVariant.set(
955
+ sel.variantId,
956
+ (byVariant.get(sel.variantId) ?? 0) + sel.quantity
957
+ );
958
+ }
959
+ const lines = Array.from(byVariant.entries()).map(
960
+ ([variantId, quantity]) => ({
961
+ merchandiseId: variantId,
962
+ quantity,
963
+ attributes: [
964
+ { key: "_lime_bundle_gid", value: bundle.id },
965
+ { key: "_lime_bundle_type", value: bundle.bundleType }
966
+ ]
967
+ })
968
+ );
910
969
  setAddingToCart(true);
911
970
  setCartError(null);
912
971
  try {
@@ -932,15 +991,7 @@ function MixMatchBundle(props) {
932
991
  } finally {
933
992
  setAddingToCart(false);
934
993
  }
935
- }, [
936
- bundle,
937
- selections,
938
- validation.valid,
939
- onAddToCart,
940
- onError,
941
- trackAddToCart,
942
- totalQuantity
943
- ]);
994
+ }, [bundle, selections, valid, onAddToCart, onError, trackAddToCart, totalQuantity]);
944
995
  if (result.status === "loading") {
945
996
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: `lb-bundle lb-bundle--loading ${className ?? ""}`, children: [
946
997
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "lb-skeleton lb-skeleton--title" }),
@@ -963,27 +1014,29 @@ function MixMatchBundle(props) {
963
1014
  children: [
964
1015
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("h3", { className: "lb-bundle__title", children: bundle.title }),
965
1016
  bundle.discountLabel && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: "lb-bundle__discount-badge", children: bundle.discountLabel }),
966
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: "lb-bundle__instructions", children: bundle.minQuantity && bundle.maxQuantity ? `Select ${bundle.minQuantity}\u2013${bundle.maxQuantity} items` : bundle.minQuantity ? `Select at least ${bundle.minQuantity} items` : bundle.maxQuantity ? `Select up to ${bundle.maxQuantity} items` : "Select your items" }),
1017
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: "lb-bundle__instructions", children: requiredPicks > 0 ? `Pick ${requiredPicks} product${requiredPicks === 1 ? "" : "s"}` : "Pick your products" }),
967
1018
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "lb-bundle__products lb-bundle__products--selectable", children: bundle.products.map((product) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
968
1019
  MixMatchProductRow,
969
1020
  {
970
1021
  bundle,
971
1022
  product,
972
1023
  currency,
1024
+ showStepper,
973
1025
  selections,
974
- onToggle: toggleProduct,
975
- onUpdateQuantity: updateQuantity
1026
+ onSelect: selectVariant,
1027
+ onDeselect: deselect,
1028
+ onSetQuantity: setQuantity
976
1029
  },
977
1030
  product.id
978
1031
  )) }),
979
- validation.message && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: "lb-bundle__validation", role: "status", children: validation.message }),
1032
+ validationMessage && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: "lb-bundle__validation", role: "status", children: validationMessage }),
980
1033
  cartError && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: "lb-bundle__error", role: "alert", children: cartError }),
981
1034
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
982
1035
  "button",
983
1036
  {
984
1037
  className: "lb-bundle__cta",
985
1038
  onClick: handleAddToCart,
986
- disabled: addingToCart || !validation.valid,
1039
+ disabled: addingToCart || !valid,
987
1040
  "aria-busy": addingToCart,
988
1041
  children: addingToCart ? "Adding..." : bundle.widgetConfig.cta.ctaText ?? `Add ${totalQuantity} Items to Cart`
989
1042
  }
@@ -996,9 +1049,11 @@ function MixMatchProductRow({
996
1049
  bundle,
997
1050
  product,
998
1051
  currency,
1052
+ showStepper,
999
1053
  selections,
1000
- onToggle,
1001
- onUpdateQuantity
1054
+ onSelect,
1055
+ onDeselect,
1056
+ onSetQuantity
1002
1057
  }) {
1003
1058
  const variants = product.variants.nodes;
1004
1059
  const optionNames = (0, import_react7.useMemo)(() => {
@@ -1006,20 +1061,12 @@ function MixMatchProductRow({
1006
1061
  return first ? first.selectedOptions.map((o) => o.name) : [];
1007
1062
  }, [variants]);
1008
1063
  const showPicker = variants.length > 1 && optionNames.length > 0;
1064
+ const rule = ruleFor(bundle, product.id);
1009
1065
  const { selectedValues, selectedVariant, setOptionValue, optionsFor } = useVariantSelection({ variants, optionNames });
1010
- const displayVariant = selectedVariant ?? variants.find(
1011
- (v) => (0, import_core9.isVariantFulfillable)(v, (0, import_core9.resolveBundleQty)(bundle, product.id, v.id))
1012
- ) ?? variants[0] ?? null;
1066
+ const displayVariant = selectedVariant ?? variants.find((v) => (0, import_core9.isVariantFulfillable)(v, rule.min)) ?? variants[0] ?? null;
1013
1067
  if (!displayVariant) return null;
1014
- const displayVariantQty = (0, import_core9.resolveBundleQty)(
1015
- bundle,
1016
- product.id,
1017
- displayVariant.id
1018
- );
1019
- const displayVariantFulfillable = (0, import_core9.isVariantFulfillable)(
1020
- displayVariant,
1021
- displayVariantQty
1022
- );
1068
+ const fulfillable = (0, import_core9.isVariantFulfillable)(displayVariant, rule.min);
1069
+ const maxAddable = (0, import_core9.maxAddableQuantity)(displayVariant, rule.max, 0);
1023
1070
  const key = `${product.id}:${displayVariant.id}`;
1024
1071
  const selected = selections.get(key);
1025
1072
  const thumbImage = displayVariant.image ?? product.featuredImage;
@@ -1048,7 +1095,7 @@ function MixMatchProductRow({
1048
1095
  unitPriceText && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: "lb-bundle__product-unit-price", children: unitPriceText }),
1049
1096
  (0, import_core9.shouldShowLowStockBadge)(
1050
1097
  displayVariant,
1051
- displayVariantQty,
1098
+ rule.min,
1052
1099
  bundle.widgetConfig.lowStockThreshold,
1053
1100
  bundle.widgetConfig.showLowStockBadge
1054
1101
  ) && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { className: "lb-bundle-low-stock-badge", children: [
@@ -1074,39 +1121,58 @@ function MixMatchProductRow({
1074
1121
  );
1075
1122
  }) })
1076
1123
  ] }),
1077
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "lb-bundle__product-actions", children: selected ? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "lb-bundle__quantity-control", children: [
1124
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "lb-bundle__product-actions", children: selected ? showStepper ? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "lb-bundle__quantity-control", children: [
1078
1125
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1079
1126
  "button",
1080
1127
  {
1081
1128
  "aria-label": `Decrease ${product.title}`,
1082
- onClick: () => onUpdateQuantity(
1129
+ onClick: () => onSetQuantity(
1083
1130
  product.id,
1084
- displayVariant.id,
1131
+ displayVariant,
1085
1132
  selected.quantity - 1
1086
1133
  ),
1134
+ disabled: selected.quantity <= rule.min,
1087
1135
  children: "\u2212"
1088
1136
  }
1089
1137
  ),
1090
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { children: selected.quantity }),
1138
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { "aria-live": "polite", children: selected.quantity }),
1091
1139
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1092
1140
  "button",
1093
1141
  {
1094
1142
  "aria-label": `Increase ${product.title}`,
1095
- onClick: () => onUpdateQuantity(
1143
+ onClick: () => onSetQuantity(
1096
1144
  product.id,
1097
- displayVariant.id,
1145
+ displayVariant,
1098
1146
  selected.quantity + 1
1099
1147
  ),
1148
+ disabled: selected.quantity >= maxAddable,
1100
1149
  children: "+"
1101
1150
  }
1151
+ ),
1152
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1153
+ "button",
1154
+ {
1155
+ className: "lb-bundle__remove-btn",
1156
+ "aria-label": `Remove ${product.title}`,
1157
+ onClick: () => onDeselect(product.id, displayVariant.id),
1158
+ children: "Remove"
1159
+ }
1102
1160
  )
1103
1161
  ] }) : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1162
+ "button",
1163
+ {
1164
+ className: "lb-bundle__select-btn lb-bundle__select-btn--selected",
1165
+ "aria-label": `Remove ${product.title}`,
1166
+ onClick: () => onDeselect(product.id, displayVariant.id),
1167
+ children: "Selected"
1168
+ }
1169
+ ) : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1104
1170
  "button",
1105
1171
  {
1106
1172
  className: "lb-bundle__select-btn",
1107
- onClick: () => onToggle(product.id, displayVariant),
1108
- disabled: !displayVariantFulfillable,
1109
- children: displayVariantFulfillable ? "Select" : "Sold out"
1173
+ onClick: () => onSelect(product.id, displayVariant),
1174
+ disabled: !fulfillable || maxAddable <= 0,
1175
+ children: fulfillable && maxAddable > 0 ? "Select" : "Sold out"
1110
1176
  }
1111
1177
  ) })
1112
1178
  ]