Skip to content

Commit 8a95644

Browse files
committed
fix: align storefront availability with inventory
1 parent ac149e9 commit 8a95644

10 files changed

Lines changed: 333 additions & 23 deletions

apps/herbatika/src/components/herbatika-product-card.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import { resolveDescription } from "@/components/product-card/product-card.description";
1212
import { resolveFlags } from "@/components/product-card/product-card.flags";
1313
import { resolveDiscountLabel } from "@/components/product-card/product-card.pricing";
14+
import { resolveVariantInventoryState } from "@/lib/storefront/product-availability";
1415

1516
export { getProductPriceLabel } from "@/components/product-card/product-card.pricing";
1617

@@ -27,9 +28,11 @@ export function HerbatikaProductCard(props: HerbatikaProductCardProps) {
2728
const { descriptionOverride, isAdding, onAddToCart } = props;
2829
const { handleImageError, imageSrc, price, productHref, title } =
2930
useHerbatikaProductCardState(product);
30-
const defaultVariantId = product.variants?.[0]?.id;
31+
const defaultVariant = product.variants?.[0] ?? null;
32+
const defaultVariantInventory = resolveVariantInventoryState(defaultVariant);
3133
const canAddToCart =
32-
Boolean(defaultVariantId) && typeof price.currentAmount === "number";
34+
defaultVariantInventory.isPurchasable &&
35+
typeof price.currentAmount === "number";
3336
const discountLabel = resolveDiscountLabel(price);
3437
const flags = resolveFlags(product, Boolean(discountLabel));
3538
const description =

apps/herbatika/src/components/product-detail/sections/product-detail-hero.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ type ProductDetailHeroProps = {
2323
galleryItems: GalleryItem[];
2424
mediaFacts: ProductMediaFact[];
2525
isAdding: boolean;
26+
maxQuantity: number;
2627
offerState: ProductOfferState;
2728
onAddToCart: () => void;
2829
onQuantityChange: (quantity: number) => void;
@@ -47,6 +48,7 @@ export function ProductDetailHero({
4748
galleryItems,
4849
mediaFacts,
4950
isAdding,
51+
maxQuantity,
5052
offerState,
5153
onAddToCart,
5254
onQuantityChange,
@@ -74,6 +76,7 @@ export function ProductDetailHero({
7476
currentAmountLabel={currentAmountLabel}
7577
displayOriginalLabel={displayOriginalLabel}
7678
isAdding={isAdding}
79+
maxQuantity={maxQuantity}
7780
offerState={offerState}
7881
onAddToCart={onAddToCart}
7982
onQuantityChange={onQuantityChange}

apps/herbatika/src/components/product-detail/sections/product-detail-purchase-panel.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ type ProductDetailPurchasePanelProps = {
5656
currentAmountLabel: string
5757
displayOriginalLabel: string | null
5858
isAdding: boolean
59+
maxQuantity: number
5960
offerState: ProductOfferState
6061
onAddToCart: () => void
6162
onQuantityChange: (quantity: number) => void
@@ -75,6 +76,7 @@ export function ProductDetailPurchasePanel({
7576
currentAmountLabel,
7677
displayOriginalLabel,
7778
isAdding,
79+
maxQuantity,
7880
offerState,
7981
onAddToCart,
8082
onQuantityChange,
@@ -238,15 +240,15 @@ export function ProductDetailPurchasePanel({
238240
<NumericInput
239241
className="min-w-0 w-full px-0 xl:px-300"
240242
id="product-quantity"
241-
max={50}
243+
max={maxQuantity}
242244
min={1}
243245
onChange={(value) => {
244246
if (!Number.isFinite(value) || value < 1) {
245247
onQuantityChange(1)
246248
return
247249
}
248250

249-
onQuantityChange(Math.floor(value))
251+
onQuantityChange(Math.min(Math.floor(value), maxQuantity))
250252
}}
251253
value={quantity}
252254
>

apps/herbatika/src/components/product-detail/use-product-detail-data.ts

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
} from "@/components/product-detail/utils/pricing-utils";
2929
import { asNumber, asRecord, asString } from "@/components/product-detail/utils/value-utils";
3030
import { resolveFreeShippingThresholdAmount } from "@/lib/storefront/free-shipping";
31+
import { resolveVariantInventoryState } from "@/lib/storefront/product-availability";
3132
import { formatCurrencyAmount } from "@/lib/storefront/price-format";
3233
import { STOREFRONT_PRODUCT_DETAIL_FIELDS, useProduct } from "@/lib/storefront/products";
3334
import { useRecordRecentlyVisitedProduct } from "@/lib/storefront/recently-visited-products";
@@ -98,6 +99,10 @@ export function useProductDetailData({ handle }: UseProductDetailDataProps) {
9899
() => resolveOfferState(product, selectedVariant),
99100
[product, selectedVariant],
100101
);
102+
const selectedVariantInventory = useMemo(
103+
() => resolveVariantInventoryState(selectedVariant, quantity),
104+
[quantity, selectedVariant],
105+
);
101106

102107
const productPrice = useMemo(() => {
103108
if (!product) {
@@ -148,7 +153,9 @@ export function useProductDetailData({ handle }: UseProductDetailDataProps) {
148153
const currentCurrencyCode = productPrice?.currencyCode ?? regionCurrencyCode;
149154
const canAddToCart =
150155
Boolean(selectedVariant?.id) &&
151-
typeof productPrice?.currentAmount === "number";
156+
typeof productPrice?.currentAmount === "number" &&
157+
selectedVariantInventory.isPurchasable;
158+
const maxQuantity = selectedVariantInventory.maxPurchaseQuantity;
152159

153160
const displayOriginalAmount = resolveDisplayOriginalAmount(productPrice);
154161

@@ -194,20 +201,26 @@ export function useProductDetailData({ handle }: UseProductDetailDataProps) {
194201
offerState.unitLabel,
195202
]);
196203

197-
const volumeDiscountOptions = useMemo(
198-
() =>
199-
resolveVolumeDiscountOptions(
200-
currentAmount,
201-
currentCurrencyCode,
202-
offerState.applyQuantityDiscount || offerState.applyVolumeDiscount,
203-
),
204-
[
204+
const volumeDiscountOptions = useMemo(() => {
205+
const options = resolveVolumeDiscountOptions(
205206
currentAmount,
206207
currentCurrencyCode,
207-
offerState.applyQuantityDiscount,
208-
offerState.applyVolumeDiscount,
209-
],
210-
);
208+
offerState.applyQuantityDiscount || offerState.applyVolumeDiscount,
209+
);
210+
const availableQuantity = selectedVariantInventory.availableQuantity;
211+
212+
if (availableQuantity === null) {
213+
return options;
214+
}
215+
216+
return options.filter((option) => option.quantity <= availableQuantity);
217+
}, [
218+
currentAmount,
219+
currentCurrencyCode,
220+
offerState.applyQuantityDiscount,
221+
offerState.applyVolumeDiscount,
222+
selectedVariantInventory.availableQuantity,
223+
]);
211224

212225
const selectedVolumeDiscountOption = useMemo(() => {
213226
if (volumeDiscountOptions.length === 0) {
@@ -230,6 +243,18 @@ export function useProductDetailData({ handle }: UseProductDetailDataProps) {
230243
setSelectedVolumeDiscountId(null);
231244
}, [product?.id, product?.variants]);
232245

246+
useEffect(() => {
247+
const availableQuantity = selectedVariantInventory.availableQuantity;
248+
249+
if (availableQuantity === null || availableQuantity < 1) {
250+
return;
251+
}
252+
253+
if (quantity > availableQuantity) {
254+
setQuantity(availableQuantity);
255+
}
256+
}, [quantity, selectedVariantInventory.availableQuantity]);
257+
233258
useEffect(() => {
234259
setSelectedVolumeDiscountId(volumeDiscountOptions[0]?.id ?? null);
235260
}, [volumeDiscountOptions]);
@@ -267,6 +292,7 @@ export function useProductDetailData({ handle }: UseProductDetailDataProps) {
267292
: formatCurrencyAmount(freeShippingThresholdAmount, currentCurrencyCode, { minimumFractionDigits: 0, maximumFractionDigits: 0}),
268293
galleryItems,
269294
isBootstrappingRegion: !region?.region_id,
295+
maxQuantity,
270296
mediaFacts,
271297
product,
272298
productCategories,

apps/herbatika/src/components/product-detail/utils/metadata-parsers.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
asRecord,
2020
asString,
2121
} from "@/components/product-detail/utils/value-utils";
22+
import { resolveVariantInventoryState } from "@/lib/storefront/product-availability";
2223

2324
const normalizeSectionKey = (value: unknown): string | null => {
2425
const parsed = asString(value);
@@ -135,9 +136,10 @@ export const resolveOfferState = (
135136
const variantMetadata = asRecord(selectedVariant?.metadata);
136137
const source = topOffer ?? variantMetadata;
137138
const stock = asRecord(source?.stock);
138-
const stockAmount = asNumber(stock?.amount);
139-
140-
const isInStock = stockAmount === null ? true : stockAmount > 0;
139+
const variantInventory = resolveVariantInventoryState(selectedVariant);
140+
const stockAmount =
141+
variantInventory.availableQuantity ?? asNumber(stock?.amount);
142+
const isInStock = variantInventory.isInStock;
141143

142144
const inStockLabel = asString(source?.availability_in_stock) ?? "Skladom";
143145
const outOfStockLabel =

apps/herbatika/src/components/storefront-product-detail.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export function StorefrontProductDetail({ handle }: StorefrontProductDetailProps
8181
galleryItems={controller.galleryItems}
8282
mediaFacts={controller.mediaFacts}
8383
isAdding={controller.isMainProductAdding}
84+
maxQuantity={controller.maxQuantity}
8485
offerState={controller.offerState}
8586
onAddToCart={controller.handleAddMainProductToCart}
8687
onQuantityChange={controller.handleQuantityChange}

apps/herbatika/src/lib/storefront/catalog-products.ts

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,17 @@ import type { HttpTypes } from "@medusajs/types";
44
import type {
55
CatalogFacets,
66
CatalogListResponse,
7+
UseCatalogProductsResult,
78
} from "@techsio/storefront-data/catalog/types";
9+
import { useMemo } from "react";
810
import {
911
PLP_PAGE_SIZE,
1012
type CatalogProductsParams,
1113
type CatalogQueryState,
1214
} from "./catalog-query-state";
15+
import { resolveVariantInventoryState } from "./product-availability";
16+
import { STOREFRONT_PRODUCT_CARD_FIELDS } from "./product-query-config";
17+
import { useProducts } from "./products";
1318
import { storefront } from "./storefront";
1419

1520
export type CatalogProductsInput = CatalogProductsParams & {
@@ -25,7 +30,117 @@ export type CatalogProductsResponse = CatalogListResponse<
2530
const catalogService = storefront.services.catalog;
2631
const catalogHooks = storefront.hooks.catalog;
2732

28-
export const useCatalogProducts = catalogHooks.useCatalogProducts;
33+
type UseCatalogProductsOptions = Parameters<
34+
typeof catalogHooks.useCatalogProducts
35+
>[1];
36+
37+
const productNeedsInventorySnapshot = (product: HttpTypes.StoreProduct) =>
38+
(product.variants ?? []).some(
39+
(variant) =>
40+
Boolean(variant.id) &&
41+
!resolveVariantInventoryState(variant).isInventoryKnown,
42+
);
43+
44+
const resolveInventorySnapshotHandles = (products: HttpTypes.StoreProduct[]) => {
45+
const handles = new Set<string>();
46+
47+
for (const product of products) {
48+
if (product.handle && productNeedsInventorySnapshot(product)) {
49+
handles.add(product.handle);
50+
}
51+
}
52+
53+
return Array.from(handles);
54+
};
55+
56+
const mergeProductInventorySnapshot = (
57+
product: HttpTypes.StoreProduct,
58+
inventoryProduct?: HttpTypes.StoreProduct,
59+
): HttpTypes.StoreProduct => {
60+
if (!inventoryProduct?.variants?.length) {
61+
return product;
62+
}
63+
64+
const inventoryVariantById = new Map(
65+
inventoryProduct.variants.map((variant) => [variant.id, variant]),
66+
);
67+
68+
return {
69+
...product,
70+
variants:
71+
product.variants?.map((variant) => {
72+
const inventoryVariant = inventoryVariantById.get(variant.id);
73+
74+
return inventoryVariant
75+
? {
76+
...variant,
77+
allow_backorder: inventoryVariant.allow_backorder,
78+
inventory_quantity: inventoryVariant.inventory_quantity,
79+
manage_inventory: inventoryVariant.manage_inventory,
80+
}
81+
: variant;
82+
}) ?? product.variants,
83+
};
84+
};
85+
86+
export const useCatalogProducts = (
87+
input: CatalogProductsInput,
88+
options?: UseCatalogProductsOptions,
89+
): UseCatalogProductsResult<HttpTypes.StoreProduct, CatalogFacets> => {
90+
const catalogQuery = catalogHooks.useCatalogProducts(input, options);
91+
const inventorySnapshotHandles = useMemo(
92+
() => resolveInventorySnapshotHandles(catalogQuery.products),
93+
[catalogQuery.products],
94+
);
95+
const shouldLoadInventorySnapshots = inventorySnapshotHandles.length > 0;
96+
const inventorySnapshotsQuery = useProducts({
97+
handle: inventorySnapshotHandles,
98+
limit: Math.max(1, inventorySnapshotHandles.length),
99+
fields: STOREFRONT_PRODUCT_CARD_FIELDS,
100+
enabled: catalogQuery.isSuccess && shouldLoadInventorySnapshots,
101+
});
102+
const inventoryProductByHandle = useMemo(
103+
() =>
104+
new Map(
105+
inventorySnapshotsQuery.products
106+
.filter((product) => product.handle)
107+
.map((product) => [product.handle, product]),
108+
),
109+
[inventorySnapshotsQuery.products],
110+
);
111+
const products = useMemo(() => {
112+
if (shouldLoadInventorySnapshots && inventorySnapshotsQuery.isLoading) {
113+
return [];
114+
}
115+
116+
return catalogQuery.products.map((product) =>
117+
mergeProductInventorySnapshot(
118+
product,
119+
product.handle
120+
? inventoryProductByHandle.get(product.handle)
121+
: undefined,
122+
),
123+
);
124+
}, [
125+
catalogQuery.products,
126+
inventoryProductByHandle,
127+
inventorySnapshotsQuery.isLoading,
128+
shouldLoadInventorySnapshots,
129+
]);
130+
131+
return {
132+
...catalogQuery,
133+
products,
134+
isLoading:
135+
catalogQuery.isLoading ||
136+
(shouldLoadInventorySnapshots && inventorySnapshotsQuery.isLoading),
137+
isFetching:
138+
catalogQuery.isFetching ||
139+
(shouldLoadInventorySnapshots && inventorySnapshotsQuery.isFetching),
140+
error: catalogQuery.error ?? inventorySnapshotsQuery.error,
141+
};
142+
};
143+
29144
export const useSuspenseCatalogProducts = catalogHooks.useSuspenseCatalogProducts;
30145
export const usePrefetchCatalogProducts = catalogHooks.usePrefetchCatalogProducts;
31146
export const prefetchCatalogProducts = catalogHooks.prefetchCatalogProducts;

0 commit comments

Comments
 (0)