fix(product): detail page price now matches list tiles + fresher feeds
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 <noreply@anthropic.com>
This commit is contained in:
parent
a405f6ffc4
commit
272362cfdf
@ -124,25 +124,58 @@ export const ProductDetailView = memo(function ProductDetailView({
|
||||
[stockedVariants.length, inStockColors.length, inStockSizes.length]
|
||||
);
|
||||
|
||||
const [selectedColor, setSelectedColor] = useState<string | null>(
|
||||
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<string | null>(
|
||||
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<string | null>(() => {
|
||||
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<string | null>(() => {
|
||||
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<number | null>(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();
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user