@propeller-commerce/propeller-v2-react-ui 0.11.4 → 0.13.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,71 @@ 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.13.0] - 2026-08-05
12
+
13
+ ### Added
14
+
15
+ - **Availability filter on `<GridFilters>`** — an opt-in section below the
16
+ price block with two checkboxes, In stock and Out of stock, wired to the
17
+ server-side stock filter the SDK exposes on `CategoryProductSearchInput`.
18
+ New props: `showAvailabilityFilter` (defaults `false`),
19
+ `activeAvailability` and `onAvailabilityChange`. Nothing selected sends no
20
+ filter, so the default listing is unchanged.
21
+
22
+ Selecting both buckets means "in stock or out of stock", which is every
23
+ product. The filter has no OR operator and the union is the unfiltered set
24
+ anyway, so both-selected sends no filter — the same as selecting neither.
25
+ The checkboxes stay ticked; only the request collapses.
26
+
27
+ The mapping lives in `propeller-v2-core-ui`'s `buildInventoryFilter`, so
28
+ this package never restates the operator semantics. Requires
29
+ `propeller-v2-core-ui` >= 0.5.0 and `propeller-sdk-v2` >= 0.15.0, where the
30
+ inventory filter was added.
31
+
32
+ - **`availability` and `onAvailabilityFilterRemove` on `<GridToolbar>`** —
33
+ the active-filter row now renders one removable chip per selected bucket,
34
+ so a stock-filtered listing shows why its results are narrowed and can be
35
+ undone from the results area. One chip per bucket, matching the attribute
36
+ chips rather than the price chip which covers a whole range. The row's
37
+ active check accounts for availability, so it appears when availability is
38
+ the only active filter.
39
+
40
+ - **`availability` on `<ProductGrid>`** — forwards the selection to
41
+ `useProductSearch`, which applies it to both the grid fetch and the
42
+ typeahead preview. Filtering only the grid would let the preview offer
43
+ products the grid then hides.
44
+
45
+ ### Changed
46
+
47
+ - The availability section is hidden for anonymous visitors in a semi-closed
48
+ portal, the same rule the price filter already follows. Hosts should pass
49
+ their own "show stock" setting through `showAvailabilityFilter`: filtering
50
+ by stock reads as broken when the cards display no stock to filter on.
51
+
52
+ ## [0.12.0] - 2026-07-31
53
+
54
+ ### Added
55
+
56
+ - **`productTrackAttributes` on `<ProductGrid>`, `<SearchBar>` and
57
+ `useProductSearch`.** Pass the attribute names a card renders — e.g.
58
+ `productTrackAttributes={['MPN']}` — and they arrive on
59
+ `product.attributes` for every item in the result, ready to read from a
60
+ card slot like `belowName`. `useProductInfo` has had an option of this name
61
+ since the PDP work; the grid side simply never forwarded one, so hosts that
62
+ set it in their portal config saw it silently ignored on listings and had
63
+ to fetch attributes themselves in a second request.
64
+
65
+ Requires `propeller-sdk-v2` >= 0.16.0, where the `category` and `products`
66
+ operations gained `$attributeResultSearchInput` and `ProductGridFields`
67
+ gained the matching `attributes(input:)` selection.
68
+
69
+ Naming the attributes you render is not just a payload optimisation — it is
70
+ the correct behaviour. With no input the server returns the *first page* of
71
+ a product's attributes (12 per product), so on a product carrying more than
72
+ 12 the ones on later pages are missing with no error. Filtering to the names
73
+ you need also cut a 24-product category response from ~168 KB to ~124 KB in
74
+ our testing.
75
+
11
76
  ## [0.11.4] - 2026-07-31
12
77
 
13
78
  ### Fixed
package/dist/index.cjs CHANGED
@@ -2090,7 +2090,7 @@ function useProductInfo(options) {
2090
2090
  if (options.user && "customerId" in options.user) input.customerId = options.user.customerId;
2091
2091
  return input;
2092
2092
  }
2093
- function buildAttributeInput() {
2093
+ function buildAttributeInput2() {
2094
2094
  const names = options.productTrackAttributes;
2095
2095
  if (!names || names.length === 0) return void 0;
2096
2096
  return { attributeDescription: { names } };
@@ -2113,7 +2113,7 @@ function useProductInfo(options) {
2113
2113
  orderlistIds = (orderlists?.items ?? []).map((ol) => ol.id);
2114
2114
  }
2115
2115
  const service = propellerV2CoreUi.createServices(graphqlClient).product;
2116
- const attributeInput = buildAttributeInput();
2116
+ const attributeInput = buildAttributeInput2();
2117
2117
  const variables = {
2118
2118
  productId,
2119
2119
  language,
@@ -2203,6 +2203,10 @@ function useProductInfo(options) {
2203
2203
  };
2204
2204
  }
2205
2205
  var inflightFetches2 = /* @__PURE__ */ new Set();
2206
+ function buildAttributeInput(names) {
2207
+ if (!names || names.length === 0) return void 0;
2208
+ return { attributeDescription: { names } };
2209
+ }
2206
2210
  function useProductSearch(options) {
2207
2211
  const { graphqlClient, configuration } = options;
2208
2212
  const isControlled = options.products !== void 0;
@@ -2257,7 +2261,8 @@ function useProductSearch(options) {
2257
2261
  userKey,
2258
2262
  JSON.stringify(options.textFilters ?? []),
2259
2263
  options.priceFilterMin ?? "",
2260
- options.priceFilterMax ?? ""
2264
+ options.priceFilterMax ?? "",
2265
+ JSON.stringify(options.availability ?? [])
2261
2266
  ].join("|");
2262
2267
  if (inflightFetches2.has(inflightKey)) {
2263
2268
  fetchIdRef.current--;
@@ -2299,6 +2304,7 @@ function useProductSearch(options) {
2299
2304
  }
2300
2305
  ] : [];
2301
2306
  const priceFilter = options.priceFilterMin !== void 0 || options.priceFilterMax !== void 0 ? { from: options.priceFilterMin ?? 0, to: options.priceFilterMax ?? 999999 } : void 0;
2307
+ const inventoryFilter = propellerV2CoreUi.buildInventoryFilter(options.availability);
2302
2308
  const userId = options.user && "contactId" in options.user ? options.user.contactId : options.user && "customerId" in options.user ? options.user.customerId : void 0;
2303
2309
  const contactId = options.user && "contactId" in options.user ? options.user.contactId : void 0;
2304
2310
  const customerId = options.user && "customerId" in options.user ? options.user.customerId : void 0;
@@ -2321,6 +2327,7 @@ function useProductSearch(options) {
2321
2327
  ...options.brand && { manufacturers: [options.brand] },
2322
2328
  ...options.textFilters?.length && { textFilters: options.textFilters },
2323
2329
  ...priceFilter && { price: priceFilter },
2330
+ ...inventoryFilter && { inventory: inventoryFilter },
2324
2331
  ...sortInputs.length && { sortInputs },
2325
2332
  ...options.companyId && { companyId: options.companyId },
2326
2333
  ...userId !== void 0 && { userId },
@@ -2335,6 +2342,7 @@ function useProductSearch(options) {
2335
2342
  const filterAvailableAttributeInput = {
2336
2343
  isSearchable: true
2337
2344
  };
2345
+ const attributeInput = buildAttributeInput(options.productTrackAttributes);
2338
2346
  const variables = {
2339
2347
  categoryId: catId,
2340
2348
  language: lang,
@@ -2342,7 +2350,8 @@ function useProductSearch(options) {
2342
2350
  priceCalculateProductInput,
2343
2351
  filterAvailableAttributeInput,
2344
2352
  imageSearchFilters: configuration?.imageSearchFiltersGrid,
2345
- imageVariantFilters: configuration?.imageVariantFiltersMedium
2353
+ imageVariantFilters: configuration?.imageVariantFiltersMedium,
2354
+ ...attributeInput && { attributeResultSearchInput: attributeInput }
2346
2355
  };
2347
2356
  const response = await service.getCategory(variables);
2348
2357
  if (thisId !== fetchIdRef.current) return;
@@ -2395,6 +2404,7 @@ function useProductSearch(options) {
2395
2404
  options.textFilters,
2396
2405
  options.priceFilterMin,
2397
2406
  options.priceFilterMax,
2407
+ options.availability,
2398
2408
  options.sortField,
2399
2409
  options.sortOrder,
2400
2410
  options.companyId,
@@ -2403,7 +2413,9 @@ function useProductSearch(options) {
2403
2413
  taxZone,
2404
2414
  pageSize,
2405
2415
  pagination.currentPage,
2406
- configuration
2416
+ configuration,
2417
+ // Stringified: callers pass an array literal, so identity changes each render.
2418
+ JSON.stringify(options.productTrackAttributes ?? [])
2407
2419
  ]);
2408
2420
  const search = React6.useCallback(
2409
2421
  (term) => {
@@ -2432,6 +2444,7 @@ function useProductSearch(options) {
2432
2444
  const userId = options.user && "contactId" in options.user ? options.user.contactId : options.user && "customerId" in options.user ? options.user.customerId : void 0;
2433
2445
  const contactId = options.user && "contactId" in options.user ? options.user.contactId : void 0;
2434
2446
  const customerId = options.user && "customerId" in options.user ? options.user.customerId : void 0;
2447
+ const inventoryFilter = propellerV2CoreUi.buildInventoryFilter(options.availability);
2435
2448
  const categoryProductSearchInput = {
2436
2449
  language,
2437
2450
  page: 1,
@@ -2443,6 +2456,7 @@ function useProductSearch(options) {
2443
2456
  propellerSdkV2.ProductStatus.S
2444
2457
  ],
2445
2458
  hidden: false,
2459
+ ...inventoryFilter && { inventory: inventoryFilter },
2446
2460
  term,
2447
2461
  searchFields: [
2448
2462
  {
@@ -2482,13 +2496,15 @@ function useProductSearch(options) {
2482
2496
  ...contactId !== void 0 && { contactId },
2483
2497
  ...customerId !== void 0 && { customerId }
2484
2498
  };
2499
+ const attributeInput = buildAttributeInput(options.productTrackAttributes);
2485
2500
  const variables = {
2486
2501
  categoryId: catId,
2487
2502
  language,
2488
2503
  categoryProductSearchInput,
2489
2504
  priceCalculateProductInput,
2490
2505
  imageSearchFilters: configuration?.imageSearchFiltersGrid,
2491
- imageVariantFilters: configuration?.imageVariantFiltersMedium
2506
+ imageVariantFilters: configuration?.imageVariantFiltersMedium,
2507
+ ...attributeInput && { attributeResultSearchInput: attributeInput }
2492
2508
  };
2493
2509
  const response = await service.getCategory(variables);
2494
2510
  const productsResponse = response?.products;
@@ -2507,7 +2523,7 @@ function useProductSearch(options) {
2507
2523
  }, 300);
2508
2524
  },
2509
2525
  // eslint-disable-next-line react-hooks/exhaustive-deps
2510
- [graphqlClient, language, configuration, userKey, JSON.stringify(options.orderlistIds ?? []), options.applyOrderlists]
2526
+ [graphqlClient, language, configuration, userKey, JSON.stringify(options.orderlistIds ?? []), options.applyOrderlists, JSON.stringify(options.productTrackAttributes ?? [])]
2511
2527
  );
2512
2528
  React6.useEffect(() => {
2513
2529
  if (!isControlled) fetchProducts();
@@ -2520,6 +2536,7 @@ function useProductSearch(options) {
2520
2536
  JSON.stringify(options.textFilters),
2521
2537
  options.priceFilterMin,
2522
2538
  options.priceFilterMax,
2539
+ options.availability,
2523
2540
  options.sortField,
2524
2541
  options.sortOrder,
2525
2542
  pageSize,
@@ -9034,6 +9051,7 @@ function ProductGrid(rawProps) {
9034
9051
  brand: props.brand,
9035
9052
  orderlistIds: props.orderlistIds,
9036
9053
  applyOrderlists: props.applyOrderlists,
9054
+ productTrackAttributes: props.productTrackAttributes,
9037
9055
  language: props.language,
9038
9056
  taxZone: props.taxZone,
9039
9057
  user: props.user,
@@ -9041,6 +9059,7 @@ function ProductGrid(rawProps) {
9041
9059
  textFilters: props.textFilters,
9042
9060
  priceFilterMin: props.priceFilterMin,
9043
9061
  priceFilterMax: props.priceFilterMax,
9062
+ availability: props.availability,
9044
9063
  sortField: props.sortField,
9045
9064
  sortOrder: props.sortOrder,
9046
9065
  pageSize: props.pageSize,
@@ -9343,11 +9362,18 @@ function GridFilters(rawProps) {
9343
9362
  const [isPending, setIsPending] = React6.useState(() => false);
9344
9363
  const [appliedMin, setAppliedMin] = React6.useState(() => void 0);
9345
9364
  const [appliedMax, setAppliedMax] = React6.useState(() => void 0);
9365
+ const [availability, setAvailability] = React6.useState(
9366
+ () => [...props.activeAvailability || []]
9367
+ );
9346
9368
  function showPriceFilter() {
9347
9369
  const mode = props.portalMode || "open";
9348
9370
  if (mode === "open") return true;
9349
9371
  return !!props.user;
9350
9372
  }
9373
+ function showAvailability() {
9374
+ if (!props.showAvailabilityFilter) return false;
9375
+ return !propellerV2CoreUi.isContentHidden(props.portalMode, props.user);
9376
+ }
9351
9377
  function getFilterName(filter) {
9352
9378
  return filter?.attributeDescription?.name || "";
9353
9379
  }
@@ -9392,6 +9418,13 @@ function GridFilters(rawProps) {
9392
9418
  props.onFilterChange(filter, value);
9393
9419
  if (props.getSelectedFilters) props.getSelectedFilters();
9394
9420
  }
9421
+ function handleAvailabilityToggle(value, checked) {
9422
+ const next = checked ? [...availability, value] : availability.filter((v) => v !== value);
9423
+ setAvailability(next);
9424
+ setIsPending(true);
9425
+ if (props.onAvailabilityChange) props.onAvailabilityChange(next);
9426
+ if (props.getSelectedFilters) props.getSelectedFilters();
9427
+ }
9395
9428
  function handleMinChange(value) {
9396
9429
  const n = value > currentMax ? currentMax : value;
9397
9430
  setCurrentMin(n);
@@ -9465,11 +9498,16 @@ function GridFilters(rawProps) {
9465
9498
  setCurrentMin(props.priceMin || 0);
9466
9499
  setCurrentMax(props.priceMax || 9999);
9467
9500
  setExpandedFilters({});
9501
+ setAvailability([]);
9468
9502
  }, [props.clearSignal, props.priceMin, props.priceMax]);
9469
9503
  React6.useEffect(() => {
9470
9504
  if (!props.activeTextFilters) return;
9471
9505
  setSelectedFilters(props.activeTextFilters);
9472
9506
  }, [props.activeTextFilters]);
9507
+ React6.useEffect(() => {
9508
+ if (!props.activeAvailability) return;
9509
+ setAvailability([...props.activeAvailability]);
9510
+ }, [props.activeAvailability]);
9473
9511
  React6.useEffect(() => {
9474
9512
  if (props.activePriceMin === void 0 && props.activePriceMax === void 0) {
9475
9513
  setCurrentMin(props.priceMin || 0);
@@ -9569,6 +9607,40 @@ function GridFilters(rawProps) {
9569
9607
  " ",
9570
9608
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "propeller-grid-filters__divider h-px bg-border-subtle" })
9571
9609
  ] }) : null,
9610
+ showAvailability() ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
9611
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "propeller-grid-filters__availability space-y-3", children: [
9612
+ /* @__PURE__ */ jsxRuntime.jsx("h3", { className: "propeller-grid-filters__availability-title text-xs font-semibold uppercase tracking-wide text-muted-foreground", children: propellerV2CoreUi.getLabel(props.labels, "availability", "Availability") }),
9613
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "propeller-grid-filters__availability-options space-y-1.5", children: [
9614
+ /* @__PURE__ */ jsxRuntime.jsxs("label", { className: "propeller-grid-filters__option flex items-center gap-2 cursor-pointer group", children: [
9615
+ /* @__PURE__ */ jsxRuntime.jsx(
9616
+ "input",
9617
+ {
9618
+ type: "checkbox",
9619
+ className: "propeller-grid-filters__checkbox h-4 w-4 rounded border-input text-secondary focus:ring-secondary cursor-pointer flex-shrink-0",
9620
+ checked: availability.includes("in-stock"),
9621
+ disabled: !!props.isLoading,
9622
+ onChange: (e) => handleAvailabilityToggle("in-stock", e.target.checked)
9623
+ }
9624
+ ),
9625
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "propeller-grid-filters__option-label flex-1 text-sm text-muted-foreground leading-none select-none group-hover:text-foreground", children: propellerV2CoreUi.getLabel(props.labels, "inStock", "In stock") })
9626
+ ] }),
9627
+ /* @__PURE__ */ jsxRuntime.jsxs("label", { className: "propeller-grid-filters__option flex items-center gap-2 cursor-pointer group", children: [
9628
+ /* @__PURE__ */ jsxRuntime.jsx(
9629
+ "input",
9630
+ {
9631
+ type: "checkbox",
9632
+ className: "propeller-grid-filters__checkbox h-4 w-4 rounded border-input text-secondary focus:ring-secondary cursor-pointer flex-shrink-0",
9633
+ checked: availability.includes("out-of-stock"),
9634
+ disabled: !!props.isLoading,
9635
+ onChange: (e) => handleAvailabilityToggle("out-of-stock", e.target.checked)
9636
+ }
9637
+ ),
9638
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "propeller-grid-filters__option-label flex-1 text-sm text-muted-foreground leading-none select-none group-hover:text-foreground", children: propellerV2CoreUi.getLabel(props.labels, "outOfStock", "Out of stock") })
9639
+ ] })
9640
+ ] })
9641
+ ] }),
9642
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "propeller-grid-filters__divider h-px bg-border-subtle" })
9643
+ ] }) : null,
9572
9644
  props.filters.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "propeller-grid-filters__empty text-sm text-foreground-subtle italic", children: propellerV2CoreUi.getLabel(props.labels, "noFiltersAvailable", "No filters available") }) : null,
9573
9645
  getFilteredFilters()?.map((filter) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "propeller-grid-filters__group border-b border-border-subtle pb-3 last:border-b-0", "data-expanded": isExpanded(getFilterName(filter)) ? "true" : "false", children: [
9574
9646
  /* @__PURE__ */ jsxRuntime.jsxs(
@@ -9744,6 +9816,8 @@ var DEFAULT_LABELS = {
9744
9816
  results: "results",
9745
9817
  perPage: " per page",
9746
9818
  price: "Price",
9819
+ inStock: "In stock",
9820
+ outOfStock: "Out of stock",
9747
9821
  switchToList: "Switch to list view",
9748
9822
  switchToGrid: "Switch to grid view"
9749
9823
  };
@@ -9772,7 +9846,8 @@ function GridToolbar(rawProps) {
9772
9846
  const text = props.activeTextFilters || {};
9773
9847
  const hasText = Object.keys(text).some((k) => (text[k] || []).length > 0);
9774
9848
  const hasPrice = props.priceFilterMin !== void 0 || props.priceFilterMax !== void 0;
9775
- return hasText || hasPrice;
9849
+ const hasAvailability = (props.availability || []).length > 0;
9850
+ return hasText || hasPrice || hasAvailability;
9776
9851
  }
9777
9852
  function getActiveFilterBadges() {
9778
9853
  const text = props.activeTextFilters || {};
@@ -9790,6 +9865,9 @@ function GridToolbar(rawProps) {
9790
9865
  function isPriceSortDisabled() {
9791
9866
  return propellerV2CoreUi.isContentHidden(props.portalMode, props.user);
9792
9867
  }
9868
+ function getAvailabilityLabel(value) {
9869
+ return value === "in-stock" ? getLabel56("inStock") : getLabel56("outOfStock");
9870
+ }
9793
9871
  function handleSortFieldChange(field) {
9794
9872
  setCurrentSortField(field);
9795
9873
  if (props.onSortChange) props.onSortChange(field, currentSortOrder);
@@ -9963,6 +10041,20 @@ function GridToolbar(rawProps) {
9963
10041
  ]
9964
10042
  }
9965
10043
  ) : null,
10044
+ (props.availability || []).map((value) => /* @__PURE__ */ jsxRuntime.jsxs(
10045
+ "span",
10046
+ {
10047
+ className: "propeller-grid-toolbar__filter-badge propeller-grid-toolbar__filter-badge--availability inline-flex items-center gap-1 cursor-pointer px-2.5 py-0.5 rounded-full text-xs font-semibold border border-input bg-background hover:bg-primary hover:text-primary-foreground hover:border-primary transition-colors",
10048
+ onClick: (event) => {
10049
+ if (props.onAvailabilityFilterRemove) props.onAvailabilityFilterRemove(value);
10050
+ },
10051
+ children: [
10052
+ getAvailabilityLabel(value),
10053
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "propeller-grid-toolbar__filter-badge-remove", children: "\xD7" })
10054
+ ]
10055
+ },
10056
+ value
10057
+ )),
9966
10058
  getActiveFilterBadges()?.map((badge) => /* @__PURE__ */ jsxRuntime.jsxs(
9967
10059
  "span",
9968
10060
  {
@@ -18396,7 +18488,8 @@ function SearchBar(rawProps) {
18396
18488
  user: props.user,
18397
18489
  companyId: props.companyId,
18398
18490
  orderlistIds: props.orderlistIds,
18399
- applyOrderlists: props.applyOrderlists
18491
+ applyOrderlists: props.applyOrderlists,
18492
+ productTrackAttributes: props.productTrackAttributes
18400
18493
  });
18401
18494
  const results = searchResults.slice(0, maxResults).map((item) => mapToSearchBarResult(item, props.language));
18402
18495
  const itemsFound = searchItemsFound;