@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/CHANGELOG.md CHANGED
@@ -8,6 +8,59 @@ once it reaches 1.0. Until then (the `0.x` line) the public API may change
8
8
  between minor versions; breaking changes are called out below and in
9
9
  [MIGRATION.md](./MIGRATION.md).
10
10
 
11
+ ## [0.27.0] - 2026-09-28
12
+
13
+ ### Fixed
14
+
15
+ - **A quantity field could not be cleared and retyped.** The typed-input handler
16
+ only committed a value once it was at or above the minimum, so with a minimum
17
+ of 2, backspacing to empty or typing "1" was ignored and the previous number
18
+ stayed stuck in the box. The field now accepts any keystroke while editing and
19
+ resolves to a valid quantity on blur. (PWP-970)
20
+
21
+ - **Incrementing an off-grid quantity kept it off-grid.** `increment()` added the
22
+ step without first snapping to the `min + n*step` grid, so a quantity that was
23
+ already off it never returned. Both steppers now snap first. (PWP-970)
24
+
25
+ - **Quick order ignored the order step.** Its quantity input was hardcoded to
26
+ `step={1}` and nothing snapped, while bulk paste and spreadsheet upload are the
27
+ primary ordering route for a wholesale account. `QuickOrderMatch` now carries
28
+ `step` alongside `minQuantity`, and typed quantities snap to it. (PWP-970)
29
+
30
+ - **`OrderItemCard` accepted a misconfiguration in silence.** It ships from the
31
+ RSC-safe `/pure` entry and reads no context, so the host must supply `language`
32
+ and `configuration.urls` — but both are optional, nothing warned, and the
33
+ failure only showed as a wrong-language name or a link that dropped the locale
34
+ prefix two clicks later. A development-only warning now names each omission
35
+ once. `titleLinkable` defaults to true, so the link case applies unless a host
36
+ opts out. (PWP-972)
37
+
38
+ ## [0.26.1] - 2026-09-28
39
+
40
+ ### Fixed
41
+
42
+ - **A quoted-price product could still be added to the cart from a grid.**
43
+ 0.26.0 guarded the PRICE on `ProductCard` but not the ACTION beside it, so a
44
+ listing showed "Price on request" next to a working quantity stepper and
45
+ add-to-cart button — and the line went in at 0. The card now offers
46
+ "Request a price", linking to the product page that owns the quote flow;
47
+ `FavoriteListItem` suppresses add-to-cart for these products outright. The
48
+ check sits before the injected-component branch, so a host-supplied control
49
+ is gated too.
50
+
51
+ - **Popovers inherited the host header’s text colour.** The search input, the
52
+ autosuggest dropdown, the cart sidebar and the cart popover each set their own
53
+ background but not their own text colour, so a shop with a dark header — which
54
+ has to colour that bar to style the triggers inside it — got white text on
55
+ their light surfaces. They are DOM children of those triggers, so the colour
56
+ cascaded straight in. Each now sets `text-foreground` explicitly, as
57
+ `AccountIconAndMenu` already did.
58
+
59
+ ### Changed
60
+
61
+ - `RequestPriceButton` uses a price-tag icon rather than a quote bubble, which
62
+ read as a comment action.
63
+
11
64
  ## [0.26.0] - 2026-09-28
12
65
 
13
66
  ### Added
@@ -42,15 +95,15 @@ between minor versions; breaking changes are called out below and in
42
95
  `priceOnRequest` renders in its place, and a card for such a product may
43
96
  carry no price object at all. Existing callers are unaffected.
44
97
 
45
- - **The PDP gates add-to-cart in the WordPress plugin's order**: `orderable`
46
- first, then the display mode. A non-orderable product gets neither control, a
47
- quoted one gets the request button, and an anonymous visitor gets the log-in
48
- action rather than a list they cannot be quoted from.
98
+ - **The PDP gates add-to-cart on `orderable` before the display mode.** A
99
+ non-orderable product gets neither control, a quoted one gets the request
100
+ button, and an anonymous visitor gets the log-in action rather than a list they
101
+ cannot be quoted from.
49
102
 
50
103
  - **Quick order excludes quoted products.** A bulk upload reports the code as
51
104
  unresolved instead of adding a line priced at 0; picking one from the
52
- typeahead clears the row with a notice. Matches the plugin, which drops them
53
- from quick-order search results server-side.
105
+ typeahead clears the row with a notice. Bulk flows have nowhere to show a
106
+ price and would submit the line at 0.
54
107
 
55
108
  ## [0.25.0] - 2026-09-25
56
109
 
package/dist/index.cjs CHANGED
@@ -2634,6 +2634,7 @@ function toMatch(item, language) {
2634
2634
  const netPrice = displayItem?.price?.net ?? 0;
2635
2635
  const grossPrice = displayItem?.price?.gross ?? 0;
2636
2636
  const minQuantity = Math.max(1, displayItem?.minimumQuantity ?? 1);
2637
+ const step = Math.max(1, displayItem?.unit ?? 1);
2637
2638
  const imageUrl = isCluster ? propellerV2CoreUi.getClusterImageUrl(item) : propellerV2CoreUi.getProductImageUrl(item);
2638
2639
  return {
2639
2640
  productId,
@@ -2643,6 +2644,7 @@ function toMatch(item, language) {
2643
2644
  netPrice,
2644
2645
  grossPrice,
2645
2646
  minQuantity,
2647
+ step,
2646
2648
  imageUrl,
2647
2649
  priceOnRequest: isPriceOnRequest(item)
2648
2650
  };
@@ -4779,6 +4781,7 @@ function AddToCart(rawProps) {
4779
4781
  onCartCreated: props.onCartCreated
4780
4782
  });
4781
4783
  const [quantity, setQuantity] = React6.useState(() => getMinQuantity(props.product));
4784
+ const [quantityDraft, setQuantityDraft] = React6.useState(null);
4782
4785
  const [toastMessage, setToastMessage] = React6.useState(() => "");
4783
4786
  const [toastType, setToastType] = React6.useState(() => "");
4784
4787
  const [toastVisible, setToastVisible] = React6.useState(() => false);
@@ -4801,16 +4804,42 @@ function AddToCart(rawProps) {
4801
4804
  function dismissToast() {
4802
4805
  setToastVisible(false);
4803
4806
  }
4807
+ function snapQuantity(value) {
4808
+ const min = getMinQuantity(props.product);
4809
+ const step = getStep(props.product);
4810
+ if (value <= min) return min;
4811
+ return Math.round((value - min) / step) * step + min;
4812
+ }
4804
4813
  function increment() {
4805
- setQuantity(quantity + getStep(props.product));
4814
+ setQuantityDraft(null);
4815
+ const snapped = snapQuantity(quantity);
4816
+ setQuantity(snapped === quantity ? quantity + getStep(props.product) : snapped);
4806
4817
  }
4807
4818
  function decrement() {
4808
4819
  const min = getMinQuantity(props.product);
4809
4820
  const step = getStep(props.product);
4821
+ setQuantityDraft(null);
4822
+ const snapped = snapQuantity(quantity);
4823
+ if (snapped !== quantity) {
4824
+ setQuantity(snapped);
4825
+ return;
4826
+ }
4810
4827
  if (quantity - step >= min) {
4811
4828
  setQuantity(quantity - step);
4812
4829
  }
4813
4830
  }
4831
+ function handleQuantityInput(raw) {
4832
+ setQuantityDraft(raw);
4833
+ const val = parseInt(raw, 10);
4834
+ if (!isNaN(val) && val >= getMinQuantity(props.product)) {
4835
+ setQuantity(snapQuantity(val));
4836
+ }
4837
+ }
4838
+ function handleQuantityBlur() {
4839
+ const val = parseInt(quantityDraft ?? "", 10);
4840
+ setQuantity(isNaN(val) ? getMinQuantity(props.product) : snapQuantity(val));
4841
+ setQuantityDraft(null);
4842
+ }
4814
4843
  function getModalImageUrl() {
4815
4844
  if (addedCartItem) {
4816
4845
  const img = addedCartItem.product?.media?.images?.items?.[0]?.imageVariants?.[0]?.url;
@@ -4940,15 +4969,9 @@ function AddToCart(rawProps) {
4940
4969
  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",
4941
4970
  min: getMinQuantity(props.product),
4942
4971
  step: getStep(props.product),
4943
- value: quantity,
4944
- onChange: (e) => {
4945
- const val = parseInt(e.target.value, 10);
4946
- const min = getMinQuantity(props.product);
4947
- const step = getStep(props.product);
4948
- if (!isNaN(val) && val >= min) {
4949
- setQuantity(Math.round((val - min) / step) * step + min);
4950
- }
4951
- }
4972
+ value: quantityDraft ?? quantity,
4973
+ onChange: (e) => handleQuantityInput(e.target.value),
4974
+ onBlur: handleQuantityBlur
4952
4975
  }
4953
4976
  ),
4954
4977
  /* @__PURE__ */ jsxRuntime.jsxs(
@@ -4973,15 +4996,9 @@ function AddToCart(rawProps) {
4973
4996
  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",
4974
4997
  min: getMinQuantity(props.product),
4975
4998
  step: getStep(props.product),
4976
- value: quantity,
4977
- onChange: (e) => {
4978
- const val = parseInt(e.target.value, 10);
4979
- const min = getMinQuantity(props.product);
4980
- const step = getStep(props.product);
4981
- if (!isNaN(val) && val >= min) {
4982
- setQuantity(Math.round((val - min) / step) * step + min);
4983
- }
4984
- }
4999
+ value: quantityDraft ?? quantity,
5000
+ onChange: (e) => handleQuantityInput(e.target.value),
5001
+ onBlur: handleQuantityBlur
4985
5002
  }
4986
5003
  ) : null,
4987
5004
  /* @__PURE__ */ jsxRuntime.jsxs(
@@ -7658,7 +7675,7 @@ function CartIconAndSidebar(rawProps) {
7658
7675
  ]
7659
7676
  }
7660
7677
  ),
7661
- props.showTotals && isHovered && totalItems > 0 ? /* @__PURE__ */ jsxRuntime.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: [
7678
+ props.showTotals && isHovered && totalItems > 0 ? /* @__PURE__ */ jsxRuntime.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: [
7662
7679
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex justify-between gap-4", children: [
7663
7680
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "propeller-cart-icon__popover-label text-muted-foreground", children: totalLabel("totalLabel") }),
7664
7681
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "propeller-cart-icon__popover-total font-semibold text-foreground", children: totalPrice })
@@ -7686,7 +7703,7 @@ function CartIconAndSidebar(rawProps) {
7686
7703
  "aria-modal": "true",
7687
7704
  "aria-label": sidebarTitle,
7688
7705
  "data-open": sidebarOpen ? "true" : "false",
7689
- 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 : ""}`),
7706
+ 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 : ""}`),
7690
7707
  children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex flex-col h-full", children: isMounted ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
7691
7708
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "propeller-cart-icon__sidebar-header flex items-center justify-between px-5 py-4 border-b border-border", children: [
7692
7709
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2", children: [
@@ -9347,7 +9364,7 @@ function ProductCardPrice(props) {
9347
9364
  ) });
9348
9365
  }
9349
9366
  function ProductCardAddToCart(props) {
9350
- const { product, resolved } = useProductCard();
9367
+ const { product, derived, resolved, handleProductClick } = useProductCard();
9351
9368
  if (propellerV2CoreUi.isContentHidden(resolved.portalMode, resolved.user, resolved.isAuthenticated)) {
9352
9369
  return /* @__PURE__ */ jsxRuntime.jsx(
9353
9370
  LoginToOrderButton_default,
@@ -9358,6 +9375,19 @@ function ProductCardAddToCart(props) {
9358
9375
  }
9359
9376
  );
9360
9377
  }
9378
+ if (isPriceOnRequest(product)) {
9379
+ return /* @__PURE__ */ jsxRuntime.jsx(
9380
+ "a",
9381
+ {
9382
+ href: derived.productUrl,
9383
+ onClick: handleProductClick,
9384
+ className: cn(
9385
+ `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 || ""}`
9386
+ ),
9387
+ children: propellerV2CoreUi.getLabel(resolved.priceLabels, "requestPrice", "Request a price")
9388
+ }
9389
+ );
9390
+ }
9361
9391
  const InjectedAddToCart = resolved.addToCartComponent;
9362
9392
  if (InjectedAddToCart) {
9363
9393
  return /* @__PURE__ */ jsxRuntime.jsx(
@@ -11913,7 +11943,7 @@ function FavoriteListItemActions(props) {
11913
11943
  className: props.className ?? "propeller-favorite-list-item__actions flex items-center gap-2 flex-shrink-0",
11914
11944
  onClick: (e) => e.stopPropagation(),
11915
11945
  children: [
11916
- resolved.allowAddToCart !== false && isProduct && !!resolved.graphqlClient ? /* @__PURE__ */ jsxRuntime.jsx(
11946
+ resolved.allowAddToCart !== false && isProduct && !!resolved.graphqlClient && !isPriceOnRequest(product) ? /* @__PURE__ */ jsxRuntime.jsx(
11917
11947
  AddToCart_default,
11918
11948
  {
11919
11949
  className: "flex items-center gap-2",
@@ -13890,6 +13920,28 @@ function OrderActions(rawProps) {
13890
13920
  );
13891
13921
  }
13892
13922
  var OrderActions_default = OrderActions;
13923
+ var warnedOnce = /* @__PURE__ */ new Set();
13924
+ function warnOnce(key, message) {
13925
+ if (warnedOnce.has(key)) return;
13926
+ warnedOnce.add(key);
13927
+ console.warn(message);
13928
+ }
13929
+ function warnMissingProps(props) {
13930
+ if (process.env.NODE_ENV === "production") return;
13931
+ if (!props.language) {
13932
+ warnOnce(
13933
+ "language",
13934
+ "[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."
13935
+ );
13936
+ }
13937
+ const linkable = props.titleLinkable !== void 0 ? props.titleLinkable : true;
13938
+ if (linkable && !props.configuration?.urls) {
13939
+ warnOnce(
13940
+ "urls",
13941
+ "[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}."
13942
+ );
13943
+ }
13944
+ }
13893
13945
  function getProductName3(orderItem, language) {
13894
13946
  return propellerV2CoreUi.getLocalizedValue(orderItem?.product?.names, language, "") || orderItem?.name || "Unknown Product";
13895
13947
  }
@@ -13913,6 +13965,7 @@ function getProductUrl(orderItem, language, urls) {
13913
13965
  function OrderItemCard(props) {
13914
13966
  const item = props.orderItem;
13915
13967
  const isChildItem = props.isChildItem || false;
13968
+ warnMissingProps(props);
13916
13969
  const titleLinkable = props.titleLinkable !== void 0 ? props.titleLinkable : true;
13917
13970
  const showImage = isChildItem ? false : props.showImage !== void 0 ? props.showImage : true;
13918
13971
  const showSku = isChildItem ? false : props.showSku !== void 0 ? props.showSku : true;
@@ -14676,7 +14729,7 @@ function RequestPriceButton(props) {
14676
14729
  if (props.onRequestPrice) props.onRequestPrice();
14677
14730
  },
14678
14731
  children: [
14679
- /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MessageSquareQuote, { className: "propeller-request-price__icon w-4 h-4", "aria-hidden": "true" }),
14732
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Tag, { className: "propeller-request-price__icon w-4 h-4", "aria-hidden": "true" }),
14680
14733
  label
14681
14734
  ]
14682
14735
  }
@@ -14684,6 +14737,10 @@ function RequestPriceButton(props) {
14684
14737
  }
14685
14738
  var RequestPriceButton_default = RequestPriceButton;
14686
14739
  var ROW_SEQ = 0;
14740
+ function snapToStep(value, min, step) {
14741
+ if (!Number.isFinite(value) || value <= min) return min;
14742
+ return Math.round((value - min) / (step || 1)) * (step || 1) + min;
14743
+ }
14687
14744
  function blankRow() {
14688
14745
  ROW_SEQ += 1;
14689
14746
  return {
@@ -14695,6 +14752,7 @@ function blankRow() {
14695
14752
  grossPrice: 0,
14696
14753
  quantity: 1,
14697
14754
  minQuantity: 1,
14755
+ step: 1,
14698
14756
  matches: [],
14699
14757
  searching: false,
14700
14758
  searched: false
@@ -14788,6 +14846,7 @@ function QuickOrderInner(props) {
14788
14846
  grossPrice: match.grossPrice,
14789
14847
  quantity: match.minQuantity,
14790
14848
  minQuantity: match.minQuantity,
14849
+ step: match.step,
14791
14850
  matches: [],
14792
14851
  searching: false,
14793
14852
  searched: false
@@ -14800,7 +14859,7 @@ function QuickOrderInner(props) {
14800
14859
  const n = parseInt(raw, 10);
14801
14860
  setRows(
14802
14861
  (prev) => prev.map(
14803
- (r) => r.key === key ? { ...r, quantity: Number.isFinite(n) && n > 0 ? Math.max(r.minQuantity, n) : r.minQuantity } : r
14862
+ (r) => r.key === key ? { ...r, quantity: snapToStep(n, r.minQuantity, r.step) } : r
14804
14863
  )
14805
14864
  );
14806
14865
  },
@@ -14853,7 +14912,8 @@ function QuickOrderInner(props) {
14853
14912
  netPrice: exact.netPrice,
14854
14913
  grossPrice: exact.grossPrice,
14855
14914
  quantity: Math.max(exact.minQuantity, line.quantity),
14856
- minQuantity: exact.minQuantity
14915
+ minQuantity: exact.minQuantity,
14916
+ step: exact.step
14857
14917
  });
14858
14918
  }
14859
14919
  if (resolved.length) {
@@ -14984,7 +15044,7 @@ function QuickOrderInner(props) {
14984
15044
  {
14985
15045
  type: "number",
14986
15046
  min: r.minQuantity,
14987
- step: 1,
15047
+ step: r.step || 1,
14988
15048
  value: r.productId ? r.quantity : "",
14989
15049
  disabled: !r.productId,
14990
15050
  onChange: (e) => setQuantity(r.key, e.target.value),
@@ -19352,7 +19412,7 @@ function SearchBar(rawProps) {
19352
19412
  // boilerplate's dark header band happened to make work: on a
19353
19413
  // light header `bg-white/95 border-white/20` rendered as an
19354
19414
  // invisible input.
19355
- "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",
19415
+ "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",
19356
19416
  props.inputClassName
19357
19417
  ),
19358
19418
  placeholder: props.placeholder || "Search products...",
@@ -19362,7 +19422,7 @@ function SearchBar(rawProps) {
19362
19422
  ),
19363
19423
  searchLoading ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "propeller-search-bar__spinner-wrapper absolute right-3 top-1/2 transform -translate-y-1/2", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "propeller-search-bar__spinner animate-spin rounded-full h-5 w-5 border-b-2 border-primary" }) }) : null
19364
19424
  ] }) }),
19365
- showDropdown ? /* @__PURE__ */ jsxRuntime.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: [
19425
+ showDropdown ? /* @__PURE__ */ jsxRuntime.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: [
19366
19426
  results.length > 0 ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
19367
19427
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "propeller-search-bar__results flex-1 overflow-y-auto", children: results?.map((result, index) => {
19368
19428
  const ResultTag = props.getResultHref ? "a" : "div";