@propeller-commerce/propeller-v2-react-ui 0.26.0 → 0.27.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.d.cts CHANGED
@@ -1548,6 +1548,8 @@ interface QuickOrderMatch {
1548
1548
  grossPrice: number;
1549
1549
  /** Minimum order quantity (defaults to 1). */
1550
1550
  minQuantity: number;
1551
+ /** Order step: quantities run min, min+step, min+2*step, … (defaults to 1). */
1552
+ step: number;
1551
1553
  /** Thumbnail URL, if any. */
1552
1554
  imageUrl: string;
1553
1555
  /** Price is quoted on request — not orderable through a bulk flow. */
package/dist/index.d.ts CHANGED
@@ -1548,6 +1548,8 @@ interface QuickOrderMatch {
1548
1548
  grossPrice: number;
1549
1549
  /** Minimum order quantity (defaults to 1). */
1550
1550
  minQuantity: number;
1551
+ /** Order step: quantities run min, min+step, min+2*step, … (defaults to 1). */
1552
+ step: number;
1551
1553
  /** Thumbnail URL, if any. */
1552
1554
  imageUrl: string;
1553
1555
  /** Price is quoted on request — not orderable through a bulk flow. */
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
7
7
  import { ProductStatus, ProductSortField, SortOrder, CrossupsellType, PriceDisplay, CartStatus, CartAddressType, Gender, YesNo, AddressType, MagicTokenService, PaymentStatuses, OrderSearchFields, OrderItemClass, ProductSearchableField, machineService, PurchaseRole, AttributeType, OrderSortField, OrderType, BundleCondition, OrderDiscountType } from '@propeller-commerce/propeller-sdk-v2';
8
8
  import { clsx } from 'clsx';
9
9
  import { twMerge } from 'tailwind-merge';
10
- import { User, Trash2, MessageSquareQuote } from 'lucide-react';
10
+ import { User, Trash2, Tag } from 'lucide-react';
11
11
 
12
12
  // src/index.ts
13
13
  var PropellerDepsContext = createContext(null);
@@ -2614,6 +2614,7 @@ function toMatch(item, language) {
2614
2614
  const netPrice = displayItem?.price?.net ?? 0;
2615
2615
  const grossPrice = displayItem?.price?.gross ?? 0;
2616
2616
  const minQuantity = Math.max(1, displayItem?.minimumQuantity ?? 1);
2617
+ const step = Math.max(1, displayItem?.unit ?? 1);
2617
2618
  const imageUrl = isCluster ? getClusterImageUrl(item) : getProductImageUrl(item);
2618
2619
  return {
2619
2620
  productId,
@@ -2623,6 +2624,7 @@ function toMatch(item, language) {
2623
2624
  netPrice,
2624
2625
  grossPrice,
2625
2626
  minQuantity,
2627
+ step,
2626
2628
  imageUrl,
2627
2629
  priceOnRequest: isPriceOnRequest(item)
2628
2630
  };
@@ -4759,6 +4761,7 @@ function AddToCart(rawProps) {
4759
4761
  onCartCreated: props.onCartCreated
4760
4762
  });
4761
4763
  const [quantity, setQuantity] = useState(() => getMinQuantity(props.product));
4764
+ const [quantityDraft, setQuantityDraft] = useState(null);
4762
4765
  const [toastMessage, setToastMessage] = useState(() => "");
4763
4766
  const [toastType, setToastType] = useState(() => "");
4764
4767
  const [toastVisible, setToastVisible] = useState(() => false);
@@ -4781,16 +4784,42 @@ function AddToCart(rawProps) {
4781
4784
  function dismissToast() {
4782
4785
  setToastVisible(false);
4783
4786
  }
4787
+ function snapQuantity(value) {
4788
+ const min = getMinQuantity(props.product);
4789
+ const step = getStep(props.product);
4790
+ if (value <= min) return min;
4791
+ return Math.round((value - min) / step) * step + min;
4792
+ }
4784
4793
  function increment() {
4785
- setQuantity(quantity + getStep(props.product));
4794
+ setQuantityDraft(null);
4795
+ const snapped = snapQuantity(quantity);
4796
+ setQuantity(snapped === quantity ? quantity + getStep(props.product) : snapped);
4786
4797
  }
4787
4798
  function decrement() {
4788
4799
  const min = getMinQuantity(props.product);
4789
4800
  const step = getStep(props.product);
4801
+ setQuantityDraft(null);
4802
+ const snapped = snapQuantity(quantity);
4803
+ if (snapped !== quantity) {
4804
+ setQuantity(snapped);
4805
+ return;
4806
+ }
4790
4807
  if (quantity - step >= min) {
4791
4808
  setQuantity(quantity - step);
4792
4809
  }
4793
4810
  }
4811
+ function handleQuantityInput(raw) {
4812
+ setQuantityDraft(raw);
4813
+ const val = parseInt(raw, 10);
4814
+ if (!isNaN(val) && val >= getMinQuantity(props.product)) {
4815
+ setQuantity(snapQuantity(val));
4816
+ }
4817
+ }
4818
+ function handleQuantityBlur() {
4819
+ const val = parseInt(quantityDraft ?? "", 10);
4820
+ setQuantity(isNaN(val) ? getMinQuantity(props.product) : snapQuantity(val));
4821
+ setQuantityDraft(null);
4822
+ }
4794
4823
  function getModalImageUrl() {
4795
4824
  if (addedCartItem) {
4796
4825
  const img = addedCartItem.product?.media?.images?.items?.[0]?.imageVariants?.[0]?.url;
@@ -4920,15 +4949,9 @@ function AddToCart(rawProps) {
4920
4949
  className: "propeller-add-to-cart__quantity flex-1 md:flex-none md:w-12 text-center text-sm bg-transparent border-none focus:ring-0 focus:outline-none h-full [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none",
4921
4950
  min: getMinQuantity(props.product),
4922
4951
  step: getStep(props.product),
4923
- value: quantity,
4924
- onChange: (e) => {
4925
- const val = parseInt(e.target.value, 10);
4926
- const min = getMinQuantity(props.product);
4927
- const step = getStep(props.product);
4928
- if (!isNaN(val) && val >= min) {
4929
- setQuantity(Math.round((val - min) / step) * step + min);
4930
- }
4931
- }
4952
+ value: quantityDraft ?? quantity,
4953
+ onChange: (e) => handleQuantityInput(e.target.value),
4954
+ onBlur: handleQuantityBlur
4932
4955
  }
4933
4956
  ),
4934
4957
  /* @__PURE__ */ jsxs(
@@ -4953,15 +4976,9 @@ function AddToCart(rawProps) {
4953
4976
  className: "propeller-add-to-cart__quantity w-full md:w-16 h-10 text-center text-sm border border-input rounded-control focus:ring-2 focus:ring-secondary focus:border-transparent [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none",
4954
4977
  min: getMinQuantity(props.product),
4955
4978
  step: getStep(props.product),
4956
- value: quantity,
4957
- onChange: (e) => {
4958
- const val = parseInt(e.target.value, 10);
4959
- const min = getMinQuantity(props.product);
4960
- const step = getStep(props.product);
4961
- if (!isNaN(val) && val >= min) {
4962
- setQuantity(Math.round((val - min) / step) * step + min);
4963
- }
4964
- }
4979
+ value: quantityDraft ?? quantity,
4980
+ onChange: (e) => handleQuantityInput(e.target.value),
4981
+ onBlur: handleQuantityBlur
4965
4982
  }
4966
4983
  ) : null,
4967
4984
  /* @__PURE__ */ jsxs(
@@ -7638,7 +7655,7 @@ function CartIconAndSidebar(rawProps) {
7638
7655
  ]
7639
7656
  }
7640
7657
  ),
7641
- props.showTotals && isHovered && totalItems > 0 ? /* @__PURE__ */ jsxs("div", { className: "propeller-cart-icon__popover absolute top-full right-0 mt-1 z-40 bg-popover border border-border rounded-container shadow-lg px-3 py-2 min-w-[140px] text-sm whitespace-nowrap", children: [
7658
+ props.showTotals && isHovered && totalItems > 0 ? /* @__PURE__ */ jsxs("div", { className: "propeller-cart-icon__popover absolute top-full right-0 mt-1 z-40 bg-popover text-foreground border border-border rounded-container shadow-lg px-3 py-2 min-w-[140px] text-sm whitespace-nowrap", children: [
7642
7659
  /* @__PURE__ */ jsxs("div", { className: "flex justify-between gap-4", children: [
7643
7660
  /* @__PURE__ */ jsx("span", { className: "propeller-cart-icon__popover-label text-muted-foreground", children: totalLabel("totalLabel") }),
7644
7661
  /* @__PURE__ */ jsx("span", { className: "propeller-cart-icon__popover-total font-semibold text-foreground", children: totalPrice })
@@ -7666,7 +7683,7 @@ function CartIconAndSidebar(rawProps) {
7666
7683
  "aria-modal": "true",
7667
7684
  "aria-label": sidebarTitle,
7668
7685
  "data-open": sidebarOpen ? "true" : "false",
7669
- className: cn(`propeller-cart-icon__sidebar fixed inset-y-0 right-0 z-[70] w-full max-w-md bg-card shadow-2xl transform transition-transform duration-300 ease-in-out border-l border-border${sidebarOpen ? " translate-x-0" : " translate-x-full"}${props.sidebarClassName ? " " + props.sidebarClassName : ""}`),
7686
+ className: cn(`propeller-cart-icon__sidebar fixed inset-y-0 right-0 z-[70] w-full max-w-md bg-card text-foreground shadow-2xl transform transition-transform duration-300 ease-in-out border-l border-border${sidebarOpen ? " translate-x-0" : " translate-x-full"}${props.sidebarClassName ? " " + props.sidebarClassName : ""}`),
7670
7687
  children: /* @__PURE__ */ jsx("div", { className: "flex flex-col h-full", children: isMounted ? /* @__PURE__ */ jsxs(Fragment, { children: [
7671
7688
  /* @__PURE__ */ jsxs("div", { className: "propeller-cart-icon__sidebar-header flex items-center justify-between px-5 py-4 border-b border-border", children: [
7672
7689
  /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
@@ -9327,7 +9344,7 @@ function ProductCardPrice(props) {
9327
9344
  ) });
9328
9345
  }
9329
9346
  function ProductCardAddToCart(props) {
9330
- const { product, resolved } = useProductCard();
9347
+ const { product, derived, resolved, handleProductClick } = useProductCard();
9331
9348
  if (isContentHidden(resolved.portalMode, resolved.user, resolved.isAuthenticated)) {
9332
9349
  return /* @__PURE__ */ jsx(
9333
9350
  LoginToOrderButton_default,
@@ -9338,6 +9355,19 @@ function ProductCardAddToCart(props) {
9338
9355
  }
9339
9356
  );
9340
9357
  }
9358
+ if (isPriceOnRequest(product)) {
9359
+ return /* @__PURE__ */ jsx(
9360
+ "a",
9361
+ {
9362
+ href: derived.productUrl,
9363
+ onClick: handleProductClick,
9364
+ className: cn(
9365
+ `propeller-product-card__view-product inline-flex h-10 w-full items-center justify-center rounded-control bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 ${props.className || ""}`
9366
+ ),
9367
+ children: getLabel(resolved.priceLabels, "requestPrice", "Request a price")
9368
+ }
9369
+ );
9370
+ }
9341
9371
  const InjectedAddToCart = resolved.addToCartComponent;
9342
9372
  if (InjectedAddToCart) {
9343
9373
  return /* @__PURE__ */ jsx(
@@ -11893,7 +11923,7 @@ function FavoriteListItemActions(props) {
11893
11923
  className: props.className ?? "propeller-favorite-list-item__actions flex items-center gap-2 flex-shrink-0",
11894
11924
  onClick: (e) => e.stopPropagation(),
11895
11925
  children: [
11896
- resolved.allowAddToCart !== false && isProduct && !!resolved.graphqlClient ? /* @__PURE__ */ jsx(
11926
+ resolved.allowAddToCart !== false && isProduct && !!resolved.graphqlClient && !isPriceOnRequest(product) ? /* @__PURE__ */ jsx(
11897
11927
  AddToCart_default,
11898
11928
  {
11899
11929
  className: "flex items-center gap-2",
@@ -13870,6 +13900,28 @@ function OrderActions(rawProps) {
13870
13900
  );
13871
13901
  }
13872
13902
  var OrderActions_default = OrderActions;
13903
+ var warnedOnce = /* @__PURE__ */ new Set();
13904
+ function warnOnce(key, message) {
13905
+ if (warnedOnce.has(key)) return;
13906
+ warnedOnce.add(key);
13907
+ console.warn(message);
13908
+ }
13909
+ function warnMissingProps(props) {
13910
+ if (process.env.NODE_ENV === "production") return;
13911
+ if (!props.language) {
13912
+ warnOnce(
13913
+ "language",
13914
+ "[OrderItemCard] No `language` prop: product names resolve in the default catalogue language, whatever page they are on. This component reads no context (it ships from the RSC-safe /pure entry), so the host must pass it."
13915
+ );
13916
+ }
13917
+ const linkable = props.titleLinkable !== void 0 ? props.titleLinkable : true;
13918
+ if (linkable && !props.configuration?.urls) {
13919
+ warnOnce(
13920
+ "urls",
13921
+ "[OrderItemCard] Title is linkable but no `configuration.urls` was passed: links fall back to a literal /product/:id/:slug and lose the locale prefix. Pass the url builders from the host, or set titleLinkable={false}."
13922
+ );
13923
+ }
13924
+ }
13873
13925
  function getProductName3(orderItem, language) {
13874
13926
  return getLocalizedValue(orderItem?.product?.names, language, "") || orderItem?.name || "Unknown Product";
13875
13927
  }
@@ -13893,6 +13945,7 @@ function getProductUrl(orderItem, language, urls) {
13893
13945
  function OrderItemCard(props) {
13894
13946
  const item = props.orderItem;
13895
13947
  const isChildItem = props.isChildItem || false;
13948
+ warnMissingProps(props);
13896
13949
  const titleLinkable = props.titleLinkable !== void 0 ? props.titleLinkable : true;
13897
13950
  const showImage = isChildItem ? false : props.showImage !== void 0 ? props.showImage : true;
13898
13951
  const showSku = isChildItem ? false : props.showSku !== void 0 ? props.showSku : true;
@@ -14656,7 +14709,7 @@ function RequestPriceButton(props) {
14656
14709
  if (props.onRequestPrice) props.onRequestPrice();
14657
14710
  },
14658
14711
  children: [
14659
- /* @__PURE__ */ jsx(MessageSquareQuote, { className: "propeller-request-price__icon w-4 h-4", "aria-hidden": "true" }),
14712
+ /* @__PURE__ */ jsx(Tag, { className: "propeller-request-price__icon w-4 h-4", "aria-hidden": "true" }),
14660
14713
  label
14661
14714
  ]
14662
14715
  }
@@ -14664,6 +14717,10 @@ function RequestPriceButton(props) {
14664
14717
  }
14665
14718
  var RequestPriceButton_default = RequestPriceButton;
14666
14719
  var ROW_SEQ = 0;
14720
+ function snapToStep(value, min, step) {
14721
+ if (!Number.isFinite(value) || value <= min) return min;
14722
+ return Math.round((value - min) / (step || 1)) * (step || 1) + min;
14723
+ }
14667
14724
  function blankRow() {
14668
14725
  ROW_SEQ += 1;
14669
14726
  return {
@@ -14675,6 +14732,7 @@ function blankRow() {
14675
14732
  grossPrice: 0,
14676
14733
  quantity: 1,
14677
14734
  minQuantity: 1,
14735
+ step: 1,
14678
14736
  matches: [],
14679
14737
  searching: false,
14680
14738
  searched: false
@@ -14768,6 +14826,7 @@ function QuickOrderInner(props) {
14768
14826
  grossPrice: match.grossPrice,
14769
14827
  quantity: match.minQuantity,
14770
14828
  minQuantity: match.minQuantity,
14829
+ step: match.step,
14771
14830
  matches: [],
14772
14831
  searching: false,
14773
14832
  searched: false
@@ -14780,7 +14839,7 @@ function QuickOrderInner(props) {
14780
14839
  const n = parseInt(raw, 10);
14781
14840
  setRows(
14782
14841
  (prev) => prev.map(
14783
- (r) => r.key === key ? { ...r, quantity: Number.isFinite(n) && n > 0 ? Math.max(r.minQuantity, n) : r.minQuantity } : r
14842
+ (r) => r.key === key ? { ...r, quantity: snapToStep(n, r.minQuantity, r.step) } : r
14784
14843
  )
14785
14844
  );
14786
14845
  },
@@ -14833,7 +14892,8 @@ function QuickOrderInner(props) {
14833
14892
  netPrice: exact.netPrice,
14834
14893
  grossPrice: exact.grossPrice,
14835
14894
  quantity: Math.max(exact.minQuantity, line.quantity),
14836
- minQuantity: exact.minQuantity
14895
+ minQuantity: exact.minQuantity,
14896
+ step: exact.step
14837
14897
  });
14838
14898
  }
14839
14899
  if (resolved.length) {
@@ -14964,7 +15024,7 @@ function QuickOrderInner(props) {
14964
15024
  {
14965
15025
  type: "number",
14966
15026
  min: r.minQuantity,
14967
- step: 1,
15027
+ step: r.step || 1,
14968
15028
  value: r.productId ? r.quantity : "",
14969
15029
  disabled: !r.productId,
14970
15030
  onChange: (e) => setQuantity(r.key, e.target.value),
@@ -19332,7 +19392,7 @@ function SearchBar(rawProps) {
19332
19392
  // boilerplate's dark header band happened to make work: on a
19333
19393
  // light header `bg-white/95 border-white/20` rendered as an
19334
19394
  // invisible input.
19335
- "propeller-search-bar__input w-full pl-10 pr-10 py-2 bg-card border border-input rounded-container focus:outline-none focus:ring-2 focus:ring-secondary placeholder:text-muted-foreground",
19395
+ "propeller-search-bar__input w-full pl-10 pr-10 py-2 bg-card text-foreground border border-input rounded-container focus:outline-none focus:ring-2 focus:ring-secondary placeholder:text-muted-foreground",
19336
19396
  props.inputClassName
19337
19397
  ),
19338
19398
  placeholder: props.placeholder || "Search products...",
@@ -19342,7 +19402,7 @@ function SearchBar(rawProps) {
19342
19402
  ),
19343
19403
  searchLoading ? /* @__PURE__ */ jsx("div", { className: "propeller-search-bar__spinner-wrapper absolute right-3 top-1/2 transform -translate-y-1/2", children: /* @__PURE__ */ jsx("div", { className: "propeller-search-bar__spinner animate-spin rounded-full h-5 w-5 border-b-2 border-primary" }) }) : null
19344
19404
  ] }) }),
19345
- showDropdown ? /* @__PURE__ */ jsxs("div", { className: "propeller-search-bar__dropdown absolute top-full left-0 right-0 mt-2 bg-card rounded-container shadow-xl border border-border z-50 flex flex-col max-h-96", children: [
19405
+ showDropdown ? /* @__PURE__ */ jsxs("div", { className: "propeller-search-bar__dropdown absolute top-full left-0 right-0 mt-2 bg-card text-foreground rounded-container shadow-xl border border-border z-50 flex flex-col max-h-96", children: [
19346
19406
  results.length > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
19347
19407
  /* @__PURE__ */ jsx("div", { className: "propeller-search-bar__results flex-1 overflow-y-auto", children: results?.map((result, index) => {
19348
19408
  const ResultTag = props.getResultHref ? "a" : "div";