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]
);
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,36 +211,29 @@ 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 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) {
} else if (
!hasColorVariants &&
!hasSizeVariants &&
stockedVariants.length > 0
) {
// No color/size dimensions → use the first available variant.
const firstVariant = stockedVariants[0];
setCurrentPrice(firstVariant.price || product.basePrice);
setCurrentDiscountPrice(
firstVariant.discountPrice &&
firstVariant.discountPrice !== "0" &&
Number(firstVariant.discountPrice) !== Number(firstVariant.price)
? firstVariant.discountPrice
: null
);
const pr = pricingOf(firstVariant);
setCurrentPrice(pr.price);
setCurrentDiscountPrice(pr.discountPrice);
setCurrentStock(firstVariant.stock || null);
} else {
setCurrentPrice(product.basePrice);
setCurrentDiscountPrice(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,
selectedSize,
@ -216,6 +242,7 @@ export const ProductDetailView = memo(function ProductDetailView({
hasColorVariants,
hasSizeVariants,
stockedVariants,
defaultVariant,
]);
const navigate = useNavigate();

View File

@ -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,
});
}