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:
fazli 2026-06-22 19:55:11 +00:00
parent a405f6ffc4
commit 272362cfdf
2 changed files with 67 additions and 40 deletions

View File

@ -124,25 +124,58 @@ export const ProductDetailView = memo(function ProductDetailView({
[stockedVariants.length, inStockColors.length, inStockSizes.length] [stockedVariants.length, inStockColors.length, inStockSizes.length]
); );
const [selectedColor, setSelectedColor] = useState<string | null>( // Effective pricing for a variant: original price + sale price (or null when
hasColorVariants && inStockColors.length > 0 // there's no real discount). Mirrors how the list/tile serializer computes
? (inStockColors[0].info as unknown as { detail: string })?.detail // discount_price so the detail page stays consistent with the tiles.
: null 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>( // Cheapest in-stock variant by final (post-discount) price — this is the
hasSizeVariants && inStockSizes.length > 0 ? inStockSizes[0].value : null // "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 // Handle product status change
const handleProductStatusChange = (newStatus: boolean) => { const handleProductStatusChange = (newStatus: boolean) => {
setProductActiveStatus(newStatus); setProductActiveStatus(newStatus);
}; };
const [currentPrice, setCurrentPrice] = useState(product.basePrice); const [currentPrice, setCurrentPrice] = useState(
() => pricingOf(defaultVariant).price
);
const [currentDiscountPrice, setCurrentDiscountPrice] = useState< const [currentDiscountPrice, setCurrentDiscountPrice] = useState<
string | null string | null
>(null); >(() => pricingOf(defaultVariant).discountPrice);
const [currentStock, setCurrentStock] = useState<number | null>(null); const [currentStock, setCurrentStock] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
@ -178,35 +211,28 @@ export const ProductDetailView = memo(function ProductDetailView({
}); });
if (matchingVariant) { if (matchingVariant) {
setCurrentPrice(matchingVariant.price || product.basePrice); const pr = pricingOf(matchingVariant);
setCurrentDiscountPrice( setCurrentPrice(pr.price);
matchingVariant.discountPrice && setCurrentDiscountPrice(pr.discountPrice);
matchingVariant.discountPrice !== "0" &&
Number(matchingVariant.discountPrice) !==
Number(matchingVariant.price)
? matchingVariant.discountPrice
: null
);
setCurrentStock(matchingVariant.stock || null); 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 { } else {
// If no matching variant found, but we have variants and no color/size requirements // Color/size not fully matched yet: show the cheapest variant's price +
// Use the first available variant (for products with no color/size variants) // sale (same as the product tile) instead of basePrice with no discount.
if (!hasColorVariants && !hasSizeVariants && stockedVariants.length > 0) { const pr = pricingOf(defaultVariant);
const firstVariant = stockedVariants[0]; setCurrentPrice(pr.price);
setCurrentPrice(firstVariant.price || product.basePrice); setCurrentDiscountPrice(pr.discountPrice);
setCurrentDiscountPrice( setCurrentStock(null);
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);
}
} }
}, [ }, [
selectedColor, selectedColor,
@ -216,6 +242,7 @@ export const ProductDetailView = memo(function ProductDetailView({
hasColorVariants, hasColorVariants,
hasSizeVariants, hasSizeVariants,
stockedVariants, stockedVariants,
defaultVariant,
]); ]);
const navigate = useNavigate(); const navigate = useNavigate();

View File

@ -75,7 +75,7 @@ export function useGetHomeDiscounts() {
} }
return undefined; return undefined;
}, },
staleTime: 5 * 60 * 1000, // 5 minutes cache staleTime: 60 * 1000, // 60s — prices/discounts must stay reasonably fresh
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
} }
@ -114,7 +114,7 @@ export function useGetHomeForYou() {
} }
return undefined; return undefined;
}, },
staleTime: 5 * 60 * 1000, // 5 minutes cache staleTime: 60 * 1000, // 60s — prices/discounts must stay reasonably fresh
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
} }
@ -153,7 +153,7 @@ export function useGetHomeTopSellers() {
} }
return undefined; return undefined;
}, },
staleTime: 5 * 60 * 1000, // 5 minutes cache staleTime: 60 * 1000, // 60s — prices/discounts must stay reasonably fresh
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
} }