@propeller-commerce/propeller-v2-react-ui 0.28.0 → 0.29.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,69 @@ 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.29.0] - 2026-10-05
12
+
13
+ ### Fixed
14
+
15
+ - **Listings no longer drop the company's orderlist on a client-side fetch.**
16
+ `useProductSearch` sent `applyOrderlists: false` whenever the caller passed
17
+ no explicit `orderlistIds` — which is every app — so the backend was told to
18
+ ignore the company's own orderlists. The server render omits the flag and is
19
+ correctly scoped, so a category page painted the right products and then the
20
+ first sort, filter, page or company change replaced them with the unscoped
21
+ catalogue, priced and orderable. The flag is now sent only when the caller
22
+ actually asks for it, in both the grid fetch and the search-bar preview.
23
+ `useQuickOrder` had the same default, which let a bulk paste resolve SKUs
24
+ outside the contract.
25
+
26
+ Callers that relied on the implicit `false` for an intentionally unscoped
27
+ listing must now pass `applyOrderlists={false}` explicitly.
28
+
29
+ - **`<ProductSlider>` is scoped to the viewer and priced for their company.**
30
+ `fetchProducts` sent no `userId`, no `companyId` and no
31
+ `priceCalculateProductInput`, and resolved its ids through the flat
32
+ `products` resolver, which does not apply orderlist scoping. A featured
33
+ slider therefore showed products outside the company's assortment at the
34
+ default company's prices. Its `filterToAssortment` step — whose only job is
35
+ dropping out-of-assortment crossupsells — asked the same resolver and so
36
+ could never filter anything. Both now go through `category.products` over
37
+ `configuration.baseCategoryId`, the path the grid and quick order already
38
+ use. Without a resolved base category the primary fetch returns nothing
39
+ rather than an unscoped list, while the crossupsell check falls through to
40
+ the unverified items so a slider is never blanked by a failed check.
41
+
42
+ - **Cart quantity steppers keep the line on the order grid.** `CartItem` read
43
+ `minimumQuantity` and the step but used them only to disable the decrement
44
+ button: the buttons added or subtracted the step from whatever was there, so
45
+ a line already off the grid stayed off it (min 2, step 3: 9 → 12). The typed
46
+ field committed only values at or above the minimum, so clearing it or typing
47
+ below the minimum left the old quantity stuck. Both steppers now resolve to
48
+ the grid first, and editing goes through a draft value that resolves on blur
49
+ — the same rule `<AddToCart>` and `<QuickOrder>` already followed.
50
+
51
+ - **Add to cart recovers when the remembered cart is gone.** A cart ordered in
52
+ another browser or deleted leaves its id behind in the host's storage, and
53
+ every later `cartAddItem` answered `CART_NOT_FOUND_ERROR`. Nothing forgot the
54
+ id, so each retry failed identically and the badge kept showing the dead
55
+ cart. `addItem` now forgets it and starts a fresh cart once, then re-adds;
56
+ the recovery is bounded because cart initialisation only ever returns an
57
+ `OPEN` cart, and it is skipped when the caller passed `createCart: false`.
58
+
59
+ - **`useProductInfo` no longer sends every company orderlist as an allowlist.**
60
+ It fetched all orderlists for the company and passed their ids to
61
+ `product(orderlistIds:)` with no filter on type, so a NEGATIVE (exclusion)
62
+ list was applied as an allowlist and inverted the filter. Scoping is left to
63
+ the backend, which merges positive and negative lists itself — one fewer
64
+ request per product page.
65
+
66
+ ### Changed
67
+
68
+ - **The min/step rule comes from `propeller-v2-core-ui`.** `AddToCart`,
69
+ `QuickOrder` and `CartItem` each carried their own copy, and they had
70
+ drifted — two of them divided by an unguarded step. All now call
71
+ `resolveOrderableQuantity` from core-ui 0.11.0, which is the new minimum for
72
+ this package.
73
+
11
74
  ## [0.28.0] - 2026-10-02
12
75
 
13
76
  ### Added
package/dist/index.cjs CHANGED
@@ -620,6 +620,14 @@ function useAuth(options) {
620
620
  }, [graphqlClient]);
621
621
  return { loading, error, login, magicLogin, createMagicToken, registerContact, registerCustomer, forgotPassword };
622
622
  }
623
+ var CART_NOT_FOUND = "CART_NOT_FOUND_ERROR";
624
+ function isCartNotFound(error) {
625
+ const entries = error?.errors;
626
+ if (!Array.isArray(entries)) return false;
627
+ return entries.some(
628
+ (e) => e?.extensions?.code === CART_NOT_FOUND || /cart:?\s*\S+\s+not found/i.test(e?.message ?? "")
629
+ );
630
+ }
623
631
  async function initCart(config) {
624
632
  const {
625
633
  services,
@@ -759,6 +767,11 @@ function useCart(options) {
759
767
  setCreatedCartId(c.cartId);
760
768
  cartIdRef.current = c.cartId;
761
769
  }, []);
770
+ const forgetCart = React6.useCallback(() => {
771
+ setCart(null);
772
+ setCreatedCartId("");
773
+ cartIdRef.current = "";
774
+ }, []);
762
775
  const imageSearchFilters = React6.useCallback(
763
776
  () => configuration?.imageSearchFiltersGrid ?? DEFAULT_IMAGE_SEARCH_FILTERS,
764
777
  [configuration]
@@ -842,13 +855,28 @@ function useCart(options) {
842
855
  } else return propellerV2CoreUi.err("No cart ID provided");
843
856
  }
844
857
  const service = propellerV2CoreUi.createServices(graphqlClient).cart;
845
- const resultCart = await service.addItemToCart({
846
- id: resolvedCartId,
847
- input: { productId: opts.product.productId, quantity: opts.quantity, ...opts.cluster?.clusterId !== void 0 && { clusterId: opts.cluster.clusterId }, ...childItemInputs && { childItems: childItemInputs }, ...opts.notes && { notes: opts.notes }, ...opts.price !== void 0 && { price: opts.price } },
848
- language,
849
- imageSearchFilters: imageSearchFilters(),
850
- imageVariantFilters: imageVariantFilters()
851
- });
858
+ const input = { productId: opts.product.productId, quantity: opts.quantity, ...opts.cluster?.clusterId !== void 0 && { clusterId: opts.cluster.clusterId }, ...childItemInputs && { childItems: childItemInputs }, ...opts.notes && { notes: opts.notes }, ...opts.price !== void 0 && { price: opts.price } };
859
+ let resultCart;
860
+ try {
861
+ resultCart = await service.addItemToCart({
862
+ id: resolvedCartId,
863
+ input,
864
+ language,
865
+ imageSearchFilters: imageSearchFilters(),
866
+ imageVariantFilters: imageVariantFilters()
867
+ });
868
+ } catch (e) {
869
+ if (!isCartNotFound(e) || !opts.createCart) throw e;
870
+ forgetCart();
871
+ const fresh = await resolveCart();
872
+ resultCart = await service.addItemToCart({
873
+ id: fresh.cartId,
874
+ input,
875
+ language,
876
+ imageSearchFilters: imageSearchFilters(),
877
+ imageVariantFilters: imageVariantFilters()
878
+ });
879
+ }
852
880
  rememberCart(resultCart);
853
881
  const addedItem = resultCart.items?.find((i) => i.productId === opts.product.productId) ?? null;
854
882
  opts.afterAddToCart?.(resultCart, addedItem);
@@ -860,7 +888,7 @@ function useCart(options) {
860
888
  } finally {
861
889
  setLoading(false);
862
890
  }
863
- }, [graphqlClient, cartId, language, configuration, resolveCart, rememberCart, imageSearchFilters, imageVariantFilters]);
891
+ }, [graphqlClient, cartId, language, configuration, resolveCart, rememberCart, forgetCart, imageSearchFilters, imageVariantFilters]);
864
892
  const addItems = React6.useCallback(async (items) => {
865
893
  const added = [];
866
894
  let lastCart = null;
@@ -2146,19 +2174,8 @@ function useProductInfo(options) {
2146
2174
  setLoading(true);
2147
2175
  setError(null);
2148
2176
  try {
2149
- let orderlistIds = [];
2150
- let applyOrderlists = true;
2151
- if (options.orderlistIds && options.orderlistIds.length > 0) {
2152
- orderlistIds = options.orderlistIds;
2153
- applyOrderlists = options.applyOrderlists !== false;
2154
- } else if (options.applyOrderlists === false) {
2155
- applyOrderlists = false;
2156
- } else if (options.user && options.companyId) {
2157
- const orderlistService = propellerV2CoreUi.createServices(graphqlClient).orderlist;
2158
- const searchInput = { companyIds: [options.companyId] };
2159
- const orderlists = await orderlistService.getOrderlists(searchInput);
2160
- orderlistIds = (orderlists?.items ?? []).map((ol) => ol.id);
2161
- }
2177
+ const orderlistIds = options.orderlistIds ?? [];
2178
+ const applyOrderlists = options.applyOrderlists !== false;
2162
2179
  const service = propellerV2CoreUi.createServices(graphqlClient).product;
2163
2180
  const attributeInput = buildAttributeInput2();
2164
2181
  const variables = {
@@ -2369,7 +2386,7 @@ function useProductSearch(options) {
2369
2386
  const orderlistScope = options.orderlistIds && options.orderlistIds.length > 0 ? {
2370
2387
  applyOrderlists: options.applyOrderlists !== false,
2371
2388
  orderlistIds: options.orderlistIds
2372
- } : { applyOrderlists: false };
2389
+ } : options.applyOrderlists === false ? { applyOrderlists: false } : {};
2373
2390
  const categoryProductSearchInput = {
2374
2391
  language: lang,
2375
2392
  page: pagination.currentPage,
@@ -2499,7 +2516,7 @@ function useProductSearch(options) {
2499
2516
  const orderlistScope = options.orderlistIds && options.orderlistIds.length > 0 ? {
2500
2517
  applyOrderlists: options.applyOrderlists !== false,
2501
2518
  orderlistIds: options.orderlistIds
2502
- } : { applyOrderlists: false };
2519
+ } : options.applyOrderlists === false ? { applyOrderlists: false } : {};
2503
2520
  const userId = resolveListingUserId(options.user, configuration);
2504
2521
  const contactId = options.user && "contactId" in options.user ? options.user.contactId : void 0;
2505
2522
  const customerId = options.user && "customerId" in options.user ? options.user.customerId : void 0;
@@ -2675,7 +2692,7 @@ function useQuickOrder(options) {
2675
2692
  if (!catId) return [];
2676
2693
  try {
2677
2694
  const service = propellerV2CoreUi.createServices(graphqlClient).category;
2678
- const orderlistScope = orderlistIds && orderlistIds.length > 0 ? { applyOrderlists: applyOrderlists !== false, orderlistIds } : { applyOrderlists: false };
2695
+ const orderlistScope = orderlistIds && orderlistIds.length > 0 ? { applyOrderlists: applyOrderlists !== false, orderlistIds } : applyOrderlists === false ? { applyOrderlists: false } : {};
2679
2696
  const userId = resolveListingUserId(user, configuration);
2680
2697
  const contactId = user && "contactId" in user ? user.contactId : void 0;
2681
2698
  const customerId = user && "customerId" in user ? user.customerId : void 0;
@@ -3284,28 +3301,33 @@ function useProductSlider(options) {
3284
3301
  }
3285
3302
  }
3286
3303
  if (!productIds.length && !clusterIds.length) return items;
3304
+ const catId = configuration.baseCategoryId;
3305
+ if (!catId) return items;
3287
3306
  const userId = resolveUserId();
3288
3307
  const companyId = resolveCompanyId();
3289
- const searchInput = {
3308
+ const categoryProductSearchInput = {
3290
3309
  ...productIds.length && { productIds },
3291
3310
  ...clusterIds.length && { clusterIds },
3292
3311
  language,
3293
3312
  page: 1,
3294
3313
  offset: 50,
3295
3314
  statuses: [propellerSdkV2.ProductStatus.A, propellerSdkV2.ProductStatus.P, propellerSdkV2.ProductStatus.T, propellerSdkV2.ProductStatus.S],
3315
+ hidden: false,
3296
3316
  ...userId !== void 0 && { userId },
3297
3317
  ...companyId !== void 0 && { companyId }
3298
3318
  };
3299
3319
  const filterAvailableAttributeInput = { isSearchable: true };
3300
3320
  const variables = {
3301
- input: searchInput,
3321
+ categoryId: catId,
3322
+ language,
3323
+ categoryProductSearchInput,
3324
+ filterAvailableAttributeInput,
3302
3325
  imageSearchFilters: configuration.imageSearchFiltersGrid,
3303
- imageVariantFilters: configuration.imageVariantFiltersMedium,
3304
- filterAvailableAttributeInput
3326
+ imageVariantFilters: configuration.imageVariantFiltersMedium
3305
3327
  };
3306
3328
  try {
3307
- const response = await propellerV2CoreUi.createServices(graphqlClient).product.getProducts(variables);
3308
- const resolved = response?.items ?? [];
3329
+ const response = await propellerV2CoreUi.createServices(graphqlClient).category.getCategory(variables);
3330
+ const resolved = response?.products?.items ?? [];
3309
3331
  const allowed = /* @__PURE__ */ new Set();
3310
3332
  for (const r of resolved) {
3311
3333
  const id = r.productId ?? r.clusterId;
@@ -3361,10 +3383,17 @@ function useProductSlider(options) {
3361
3383
  setLoading(true);
3362
3384
  setError(null);
3363
3385
  try {
3364
- const service = propellerV2CoreUi.createServices(graphqlClient).product;
3365
- const searchInput = {
3366
- productIds,
3367
- clusterIds,
3386
+ const catId = configuration.baseCategoryId;
3387
+ if (!catId) {
3388
+ setProducts([]);
3389
+ return;
3390
+ }
3391
+ const service = propellerV2CoreUi.createServices(graphqlClient).category;
3392
+ const userId = resolveUserId();
3393
+ const companyId = resolveCompanyId();
3394
+ const categoryProductSearchInput = {
3395
+ ...productIds.length && { productIds },
3396
+ ...clusterIds.length && { clusterIds },
3368
3397
  language,
3369
3398
  page: 1,
3370
3399
  offset: 50,
@@ -3373,17 +3402,24 @@ function useProductSlider(options) {
3373
3402
  propellerSdkV2.ProductStatus.P,
3374
3403
  propellerSdkV2.ProductStatus.T,
3375
3404
  propellerSdkV2.ProductStatus.S
3376
- ]
3405
+ ],
3406
+ hidden: false,
3407
+ ...userId !== void 0 && { userId },
3408
+ ...companyId !== void 0 && { companyId }
3377
3409
  };
3378
3410
  const filterAvailableAttributeInput = { isSearchable: true };
3379
3411
  const variables = {
3380
- input: searchInput,
3412
+ categoryId: catId,
3413
+ language,
3414
+ categoryProductSearchInput,
3415
+ priceCalculateProductInput: buildPriceInput(),
3416
+ filterAvailableAttributeInput,
3381
3417
  imageSearchFilters: configuration.imageSearchFiltersGrid,
3382
- imageVariantFilters: configuration.imageVariantFiltersMedium,
3383
- filterAvailableAttributeInput
3418
+ imageVariantFilters: configuration.imageVariantFiltersMedium
3384
3419
  };
3385
- const response = await service.getProducts(variables);
3386
- setProducts(response?.items ?? []);
3420
+ const response = await service.getCategory(variables);
3421
+ const productsResponse = response?.products;
3422
+ setProducts(productsResponse?.items ?? []);
3387
3423
  } catch (e) {
3388
3424
  setError(e instanceof Error ? e.message : "Failed to fetch products");
3389
3425
  setProducts([]);
@@ -4827,10 +4863,7 @@ function AddToCart(rawProps) {
4827
4863
  setToastVisible(false);
4828
4864
  }
4829
4865
  function snapQuantity(value) {
4830
- const min = getMinQuantity(props.product);
4831
- const step = getStep(props.product);
4832
- if (value <= min) return min;
4833
- return Math.round((value - min) / step) * step + min;
4866
+ return propellerV2CoreUi.resolveOrderableQuantity(value, getMinQuantity(props.product), getStep(props.product));
4834
4867
  }
4835
4868
  function increment() {
4836
4869
  setQuantityDraft(null);
@@ -6939,6 +6972,7 @@ function CartItem(rawProps) {
6939
6972
  companyId: props.companyId
6940
6973
  });
6941
6974
  const [quantity, setQuantity] = React6.useState(() => props.cartItem.quantity || 1);
6975
+ const [quantityDraft, setQuantityDraft] = React6.useState(() => null);
6942
6976
  const [notes, setNotes] = React6.useState(() => props.cartItem.notes || "");
6943
6977
  const [loading, setLoading] = React6.useState(() => false);
6944
6978
  const [deleting, setDeleting] = React6.useState(() => false);
@@ -7062,6 +7096,34 @@ function CartItem(rawProps) {
7062
7096
  setLoading(false);
7063
7097
  }
7064
7098
  }
7099
+ function handleQuantityDecrement() {
7100
+ setQuantityDraft(null);
7101
+ const orderable = propellerV2CoreUi.resolveOrderableQuantity(quantity, minQuantity, step);
7102
+ if (orderable !== quantity) {
7103
+ handleQuantityChange(orderable);
7104
+ return;
7105
+ }
7106
+ if (quantity - step >= minQuantity) handleQuantityChange(quantity - step);
7107
+ }
7108
+ function handleQuantityIncrement() {
7109
+ setQuantityDraft(null);
7110
+ const orderable = propellerV2CoreUi.resolveOrderableQuantity(quantity, minQuantity, step);
7111
+ handleQuantityChange(orderable === quantity ? quantity + step : orderable);
7112
+ }
7113
+ function handleQuantityInput(raw) {
7114
+ setQuantityDraft(raw);
7115
+ const val = parseInt(raw, 10);
7116
+ if (!isNaN(val) && val >= minQuantity) {
7117
+ handleQuantityChange(propellerV2CoreUi.resolveOrderableQuantity(val, minQuantity, step));
7118
+ }
7119
+ }
7120
+ function handleQuantityBlur() {
7121
+ if (quantityDraft === null) return;
7122
+ const val = parseInt(quantityDraft, 10);
7123
+ setQuantityDraft(null);
7124
+ const orderable = propellerV2CoreUi.resolveOrderableQuantity(val, minQuantity, step);
7125
+ if (orderable !== quantity) handleQuantityChange(orderable);
7126
+ }
7065
7127
  function handleNoteChange(note) {
7066
7128
  setNotes(note);
7067
7129
  if (props.onNoteChange) {
@@ -7164,7 +7226,8 @@ function CartItem(rawProps) {
7164
7226
  deleting,
7165
7227
  crossupsells,
7166
7228
  visibleCrossupsells: getVisibleCrossupsells(),
7167
- addingCrossupsellId
7229
+ addingCrossupsellId,
7230
+ quantityDraft
7168
7231
  },
7169
7232
  helpers: {
7170
7233
  getBundleItemName,
@@ -7177,6 +7240,10 @@ function CartItem(rawProps) {
7177
7240
  },
7178
7241
  handlers: {
7179
7242
  onQuantityChange: handleQuantityChange,
7243
+ onQuantityDecrement: handleQuantityDecrement,
7244
+ onQuantityIncrement: handleQuantityIncrement,
7245
+ onQuantityInput: handleQuantityInput,
7246
+ onQuantityBlur: handleQuantityBlur,
7180
7247
  onNoteChange: handleNoteChange,
7181
7248
  onDelete: handleDelete,
7182
7249
  onTitleClick: (e) => props.onTitleClick?.(e, props.cartItem),
@@ -7491,7 +7558,7 @@ function CartItemQuantity(props = {}) {
7491
7558
  {
7492
7559
  type: "button",
7493
7560
  className: "propeller-cart-item__decrement px-2.5 h-full text-muted-foreground hover:bg-surface-hover disabled:opacity-40 disabled:cursor-not-allowed transition-colors rounded-l-control select-none",
7494
- onClick: () => handlers.onQuantityChange(derived.quantity - derived.step),
7561
+ onClick: handlers.onQuantityDecrement,
7495
7562
  disabled: derived.quantity <= derived.minQuantity || state.loading,
7496
7563
  children: "-"
7497
7564
  }
@@ -7503,13 +7570,9 @@ function CartItemQuantity(props = {}) {
7503
7570
  className: "propeller-cart-item__quantity w-10 text-center text-sm bg-transparent border-x border-input h-full focus:ring-0 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none",
7504
7571
  min: derived.minQuantity,
7505
7572
  step: derived.step,
7506
- value: derived.quantity,
7507
- onChange: (e) => {
7508
- const val = parseInt(e.target.value, 10);
7509
- if (!isNaN(val) && val >= derived.minQuantity) {
7510
- handlers.onQuantityChange(Math.round((val - derived.minQuantity) / derived.step) * derived.step + derived.minQuantity);
7511
- }
7512
- }
7573
+ value: state.quantityDraft ?? derived.quantity,
7574
+ onChange: (e) => handlers.onQuantityInput(e.target.value),
7575
+ onBlur: handlers.onQuantityBlur
7513
7576
  }
7514
7577
  ),
7515
7578
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -7517,7 +7580,7 @@ function CartItemQuantity(props = {}) {
7517
7580
  {
7518
7581
  type: "button",
7519
7582
  className: "propeller-cart-item__increment px-2.5 h-full text-muted-foreground hover:bg-surface-hover disabled:opacity-40 disabled:cursor-not-allowed transition-colors rounded-r-control select-none",
7520
- onClick: () => handlers.onQuantityChange(derived.quantity + derived.step),
7583
+ onClick: handlers.onQuantityIncrement,
7521
7584
  disabled: state.loading,
7522
7585
  children: "+"
7523
7586
  }
@@ -7531,13 +7594,9 @@ function CartItemQuantity(props = {}) {
7531
7594
  className: props.className ?? "propeller-cart-item__quantity w-14 h-9 text-center text-sm border border-input rounded-control focus:ring-2 focus:ring-primary focus:border-transparent [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none",
7532
7595
  min: derived.minQuantity,
7533
7596
  step: derived.step,
7534
- value: derived.quantity,
7535
- onChange: (e) => {
7536
- const val = parseInt(e.target.value, 10);
7537
- if (!isNaN(val) && val >= derived.minQuantity) {
7538
- handlers.onQuantityChange(Math.round((val - derived.minQuantity) / derived.step) * derived.step + derived.minQuantity);
7539
- }
7540
- }
7597
+ value: state.quantityDraft ?? derived.quantity,
7598
+ onChange: (e) => handlers.onQuantityInput(e.target.value),
7599
+ onBlur: handlers.onQuantityBlur
7541
7600
  }
7542
7601
  );
7543
7602
  }
@@ -14795,10 +14854,6 @@ function RequestPriceButton(props) {
14795
14854
  }
14796
14855
  var RequestPriceButton_default = RequestPriceButton;
14797
14856
  var ROW_SEQ = 0;
14798
- function snapToStep(value, min, step) {
14799
- if (!Number.isFinite(value) || value <= min) return min;
14800
- return Math.round((value - min) / (step || 1)) * (step || 1) + min;
14801
- }
14802
14857
  function blankRow() {
14803
14858
  ROW_SEQ += 1;
14804
14859
  return {
@@ -14918,7 +14973,7 @@ function QuickOrderInner(props) {
14918
14973
  const n = parseInt(raw, 10);
14919
14974
  setRows(
14920
14975
  (prev) => prev.map(
14921
- (r) => r.key === key ? { ...r, quantity: snapToStep(n, r.minQuantity, r.step) } : r
14976
+ (r) => r.key === key ? { ...r, quantity: propellerV2CoreUi.resolveOrderableQuantity(n, r.minQuantity, r.step) } : r
14922
14977
  )
14923
14978
  );
14924
14979
  },