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]
|
[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();
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user