From 272362cfdf3bfe67041f53727d62ec6da6e12826 Mon Sep 17 00:00:00 2001 From: fazli Date: Mon, 22 Jun 2026 19:55:11 +0000 Subject: [PATCH] fix(product): detail page price now matches list tiles + fresher feeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-product page showed basePrice with no discount while list tiles showed the cheapest variant's discounted price. Cause: the detail view defaulted selectedColor/selectedSize to the first in-stock color and the first in-stock size independently — a combo that often isn't a real variant, so no variant matched and it fell back to basePrice / no sale. - ProductDetailView: derive a `defaultVariant` (cheapest in-stock variant by final price, == the tile's min/discount price) and use it for the initial selection, the initial displayed price, and the fallback when no exact color/size combo is matched. A shared `pricingOf` helper applies the same discount rule everywhere. Detail price now matches the tile. - use-home-hooks: drop staleTime 5m -> 60s on the For You / discounts / top-sellers feeds so price/discount edits show up promptly in lists. Co-Authored-By: Claude Opus 4.8 --- app/components/product/ProductDetailView.tsx | 101 ++++++++++++------- app/requestHandler/use-home-hooks.ts | 6 +- 2 files changed, 67 insertions(+), 40 deletions(-) diff --git a/app/components/product/ProductDetailView.tsx b/app/components/product/ProductDetailView.tsx index 0e232d3..e2bd9bc 100644 --- a/app/components/product/ProductDetailView.tsx +++ b/app/components/product/ProductDetailView.tsx @@ -124,25 +124,58 @@ export const ProductDetailView = memo(function ProductDetailView({ [stockedVariants.length, inStockColors.length, inStockSizes.length] ); - const [selectedColor, setSelectedColor] = useState( - hasColorVariants && inStockColors.length > 0 - ? (inStockColors[0].info as unknown as { detail: string })?.detail - : null - ); + // Effective pricing for a variant: original price + sale price (or null when + // there's no real discount). Mirrors how the list/tile serializer computes + // discount_price so the detail page stays consistent with the tiles. + const pricingOf = (v?: (typeof stockedVariants)[number] | null) => ({ + price: v?.price || product.basePrice, + discountPrice: + v?.discountPrice && + v.discountPrice !== "0" && + Number(v.discountPrice) !== Number(v.price) + ? v.discountPrice + : null, + }); - const [selectedSize, setSelectedSize] = useState( - hasSizeVariants && inStockSizes.length > 0 ? inStockSizes[0].value : null - ); + // Cheapest in-stock variant by final (post-discount) price — this is the + // "starting from" price the product tile shows. Used as the default + // selection and as the fallback when no exact color/size combo is matched. + const defaultVariant = useMemo(() => { + const finalOf = (v: (typeof stockedVariants)[number]) => { + const pr = pricingOf(v); + return Number(pr.discountPrice ?? pr.price ?? 0); + }; + const pool = + stockedVariants.length > 0 ? stockedVariants : product.variants ?? []; + if (pool.length === 0) return null; + return pool.reduce((best, v) => (finalOf(v) < finalOf(best) ? v : best)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [stockedVariants, product.variants]); + + const [selectedColor, setSelectedColor] = useState(() => { + if (!hasColorVariants || inStockColors.length === 0) return null; + const colorVal = defaultVariant?.resolvedAttributes?.COLOR; + const match = + inStockColors.find((c) => c.value === colorVal) ?? inStockColors[0]; + return (match.info as unknown as { detail: string })?.detail ?? null; + }); + + const [selectedSize, setSelectedSize] = useState(() => { + if (!hasSizeVariants || inStockSizes.length === 0) return null; + return defaultVariant?.resolvedAttributes?.SIZE ?? inStockSizes[0].value; + }); // Handle product status change const handleProductStatusChange = (newStatus: boolean) => { setProductActiveStatus(newStatus); }; - const [currentPrice, setCurrentPrice] = useState(product.basePrice); + const [currentPrice, setCurrentPrice] = useState( + () => pricingOf(defaultVariant).price + ); const [currentDiscountPrice, setCurrentDiscountPrice] = useState< string | null - >(null); + >(() => pricingOf(defaultVariant).discountPrice); const [currentStock, setCurrentStock] = useState(null); useEffect(() => { @@ -178,35 +211,28 @@ export const ProductDetailView = memo(function ProductDetailView({ }); if (matchingVariant) { - setCurrentPrice(matchingVariant.price || product.basePrice); - setCurrentDiscountPrice( - matchingVariant.discountPrice && - matchingVariant.discountPrice !== "0" && - Number(matchingVariant.discountPrice) !== - Number(matchingVariant.price) - ? matchingVariant.discountPrice - : null - ); + const pr = pricingOf(matchingVariant); + setCurrentPrice(pr.price); + setCurrentDiscountPrice(pr.discountPrice); setCurrentStock(matchingVariant.stock || null); + } else if ( + !hasColorVariants && + !hasSizeVariants && + stockedVariants.length > 0 + ) { + // No color/size dimensions → use the first available variant. + const firstVariant = stockedVariants[0]; + const pr = pricingOf(firstVariant); + setCurrentPrice(pr.price); + setCurrentDiscountPrice(pr.discountPrice); + setCurrentStock(firstVariant.stock || null); } else { - // If no matching variant found, but we have variants and no color/size requirements - // Use the first available variant (for products with no color/size variants) - if (!hasColorVariants && !hasSizeVariants && stockedVariants.length > 0) { - const firstVariant = stockedVariants[0]; - setCurrentPrice(firstVariant.price || product.basePrice); - setCurrentDiscountPrice( - firstVariant.discountPrice && - firstVariant.discountPrice !== "0" && - Number(firstVariant.discountPrice) !== Number(firstVariant.price) - ? firstVariant.discountPrice - : null - ); - setCurrentStock(firstVariant.stock || null); - } else { - setCurrentPrice(product.basePrice); - setCurrentDiscountPrice(null); - setCurrentStock(null); - } + // Color/size not fully matched yet: show the cheapest variant's price + + // sale (same as the product tile) instead of basePrice with no discount. + const pr = pricingOf(defaultVariant); + setCurrentPrice(pr.price); + setCurrentDiscountPrice(pr.discountPrice); + setCurrentStock(null); } }, [ selectedColor, @@ -216,6 +242,7 @@ export const ProductDetailView = memo(function ProductDetailView({ hasColorVariants, hasSizeVariants, stockedVariants, + defaultVariant, ]); const navigate = useNavigate(); diff --git a/app/requestHandler/use-home-hooks.ts b/app/requestHandler/use-home-hooks.ts index f53ca47..7c96320 100644 --- a/app/requestHandler/use-home-hooks.ts +++ b/app/requestHandler/use-home-hooks.ts @@ -75,7 +75,7 @@ export function useGetHomeDiscounts() { } return undefined; }, - staleTime: 5 * 60 * 1000, // 5 minutes cache + staleTime: 60 * 1000, // 60s — prices/discounts must stay reasonably fresh refetchOnWindowFocus: false, }); } @@ -114,7 +114,7 @@ export function useGetHomeForYou() { } return undefined; }, - staleTime: 5 * 60 * 1000, // 5 minutes cache + staleTime: 60 * 1000, // 60s — prices/discounts must stay reasonably fresh refetchOnWindowFocus: false, }); } @@ -153,7 +153,7 @@ export function useGetHomeTopSellers() { } return undefined; }, - staleTime: 5 * 60 * 1000, // 5 minutes cache + staleTime: 60 * 1000, // 60s — prices/discounts must stay reasonably fresh refetchOnWindowFocus: false, }); }