| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312 |
- "use client";
- import { useState, useMemo } from "react";
- import { redirect, RedirectType } from 'next/navigation'
- import { useAppDispatch, useAppSelector } from "@/store/hooks";
- import { useCustomToast } from "@utils/hooks/useToast";
- import {
- closeAddToCartDialog,
- clearAddToCartProduct
- } from '@/store/slices/addToCartDialogSlice';
- import { useAddProduct } from "@utils/hooks/useAddToCart";
- import Portal from "@/components/Portal";
- import {
- Drawer,
- DrawerContent,
- DrawerHeader,
- DrawerBody,
- DrawerFooter
- } from "@heroui/drawer";
- import {
- ProductOption,
- ResolvedVariant
- } from "@/components/catalog/type";
- import ImgSwiperInAddToCartModal from "./ImgSwiperInAddToCartModal";
- import ProductOptionsInAddToCartModal from "./ProductOptionsInAddToCartModal";
- import FooterBtnInAddToCartModal from "./FooterBtnInAddToCartModal";
- import { Price } from "@components/theme/ui/Price";
- import {
- isValueAvailable,
- isOptionValueAvailable,
- getFirstAvailable,
- getAvailableVariants,
- isCombinationAvailable,
- formatFlexibleVariants,
- findNearestAvailableVariant
- } from "@/utils/variantTools";
- export function AddToCartModal() {
- const dispatch = useAppDispatch();
- const {isOpen, product} = useAppSelector((state) => state.addToCartDialog);
- const { isCartLoading, onAddToCart } = useAddProduct();
- const { showToast } = useCustomToast();
- const images = useMemo(() => {
- return product?.images?.edges.map((item) => {
- return item.node;
- }) ?? [];
- },[product?.images]);
- const flexibleVariants = useMemo(() => {
- return formatFlexibleVariants(product?.flexibleVariants);
- },[product?.flexibleVariants]);
- const productOptions: ProductOption[] = useMemo(() => {
- return product?.productOptions ? JSON.parse(product.productOptions) : [];
- },[product?.productOptions]);
- // 获取所有可用变体
- const availableVariants = useMemo(() => {
- return getAvailableVariants(flexibleVariants);
- },[flexibleVariants]);
- const isSaleable = product?.isSaleable;
- const [productQty, setProductQty] = useState(1);
- // 记录当前哪个选项组刚刚被点击(用于实现“点击组内全部可点”)
- const [lastClickedOptionId, setLastClickedOptionId] = useState<number>(productOptions[0]?.id );
-
- // 当前选中的选项值映射:option_id -> value_id
- // useEffect和useMemo的执行时机不同,useMemo早于useEffect(useEffect在浏览器绘制后执行)执行,
- // useLayoutEffect早于useEffect执行,useLayoutEffect在浏览器绘制之前执行(官方说会阻塞浏览器绘制)
- const [selected, setSelected] = useState<Record<number, number>>(() => {
- // if (Object.keys(selected).length === 0) {
- let res: Record<number, number> = {};
- const defaultSelected = getFirstAvailable(productOptions, flexibleVariants);
- if (defaultSelected) {
- res = defaultSelected;
- } else {
- // 极端情况:没有任何可用组合,默认全选每组第一个(但仍需标记为不可用,由禁用逻辑处理)
- productOptions.forEach((opt) => {
- res[opt.id] = opt.values[0]?.id;
- });
- }
- console.log('set default selected ---- ', res);
- return res;
- // }
- });
- // 计算每个选项值的禁用状态
- const disabledMap = useMemo(() => {
- const map: Record<number, boolean> = {};
- if (Object.keys(selected).length === 0) return map; // 没有选中的项
- productOptions.forEach((option) => {
- option.values.forEach((value) => {
- // 规则:如果该选项组是最近点击的组,校验组中的值是否存在可用变体
- if (option.id === lastClickedOptionId) {
- // map[value.id] = false;
- map[value.id] = !isValueAvailable(value.id,availableVariants);
- } else {
- // 校验其他组的选项值是否可用
- map[value.id] = !isOptionValueAvailable(
- option.id,
- value.id,
- selected,
- productOptions,
- availableVariants
- );
- }
- });
- });
- return map;
- }, [selected, lastClickedOptionId, productOptions, availableVariants]);
-
- // 判断当前选中的组合是否可购买(用于控制购买按钮等)
- const isCurrentSelectionAvailable = useMemo(() => {
- let res = true;
- if(isSaleable !== '1') {
- res = false;
- } else {
- const selectedIds = Object.values(selected);
- if (selectedIds.length !== productOptions.length) {
- res = false
- } else {
- res = isCombinationAvailable(selectedIds, availableVariants);
- }
- }
- return res;
- }, [selected, productOptions, availableVariants, isSaleable]);
-
- // 获取当前选中变体和价格等信息
- const currentVariantInfo: {variant:ResolvedVariant | null; totalLinePrice: number; totalNowPrice: number; save: number;} = useMemo(() => {
- const selectedIds = Object.values(selected);
- let totalLinePrice = 0;
- let totalNowPrice = 0;
- let save = 0;
- let variant = flexibleVariants.find((v) => {
- const vIds = v.optionValues.map((ov) => ov.id);
- return selectedIds.length === vIds.length && selectedIds.every((id) => vIds.includes(id));
- });
- if (selectedIds.length === 0 || variant === undefined) {
- // 尚未初始化,返回一个安全占位对象
- return { variant: null, totalLinePrice: 0, totalNowPrice: 0, save: 0 };
- }
- if(variant.priceIndices && variant.priceIndices.length > 0) {
- totalLinePrice = Number(variant.priceIndices[0].regular_min_price) * productQty;
- totalNowPrice = Number(variant.priceIndices[0].min_price) * productQty;
- }
- if(totalLinePrice !== totalNowPrice) {
- save = -(totalLinePrice - totalNowPrice);
- }
- return {
- variant,
- totalLinePrice: totalLinePrice,
- totalNowPrice: totalNowPrice,
- save: save
- };
- }, [selected, flexibleVariants, productQty]);
- const handleOptionClick = (result:{
- clickOptionId: number;
- clickValueId: number;
- }) => {
- // 更新前需要判断当前选中的组合是否可以购买,如果可以购买正常更新;如果不能购买查找可以购买的组合然后更新
- let {clickOptionId: optionId, clickValueId: valueId} = result;
- let newSelected: Record<number, number> = {...selected,[optionId]: valueId};
- let selectedValueIds = Object.values(newSelected);
- if(!isCombinationAvailable(selectedValueIds, availableVariants)) {
- newSelected = findNearestAvailableVariant(optionId,valueId,{...newSelected},productOptions,availableVariants);
- }
- setSelected(newSelected);
- // 记录当前点击的选项组
- setLastClickedOptionId(optionId);
- };
- async function addProductToCart(action:string = 'addtocart') {
-
- let params = {
- productId: String(product?._id),
- quantity: productQty,
- variantId: currentVariantInfo.variant?._id,
- };
- let res = await onAddToCart(params);
- console.log('onAddToCart run ----- 1');
- if(action === 'buynow') {
- if(res) {
- const responseData = res.data?.createAddProductInCart?.addProductInCart;
- if(responseData && responseData.success) {
- redirect('/checkout?step=address', RedirectType.push);
- }
- }
- }
- }
- const addToCartHandler = async (callback: ()=> void) => {
- if(!isCurrentSelectionAvailable) {
- showToast("The selected options are not available!", "warning");
- return;
- }
- if(currentVariantInfo.variant && !isCartLoading) {
- await addProductToCart();
- callback();
- }
- };
- const buyNowHandler = async (callback: ()=> void) => {
- if(!isCurrentSelectionAvailable) {
- showToast("The selected options are not available!", "warning");
- return;
- }
- if(currentVariantInfo.variant && !isCartLoading) {
- await addProductToCart('buynow');
- callback();
- }
- };
- const closeModal = () => {
- dispatch(closeAddToCartDialog());
- setTimeout(() => {
- dispatch(clearAddToCartProduct());
- }, 300);
- };
- const openChange = (e:boolean) => {
- if(!e) {
- closeModal();
- }
- }
- return (
- <Portal>
- <Drawer
- backdrop={"blur"}
- placement="bottom"
- isDismissable={false}
- isKeyboardDismissDisabled={true}
- isOpen={isOpen}
- hideCloseButton
- onOpenChange={(e) => openChange(e)}
- >
- <DrawerContent className="rounded-none h-17/20 max-h-none">
- {(onClose) => (
- <>
- <DrawerHeader className="flex flex-col gap-1">
- <div>
- Select Options
- <button className="absolute top-2.5 right-2.5 w-6 h-6" onClick={onClose}>
- <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon" className="h-6 transition-all ease-in-out hover:scale-110"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12"></path></svg>
- </button>
- </div>
- {/**产品图片 */}
- {images.length > 0 && (
- <div>
- <ImgSwiperInAddToCartModal images={images} />
- </div>
- )}
- </DrawerHeader>
- <DrawerBody className="drawer-body relative">
- <div>
- {/**产品选项 */}
- <div className="box-border w-full">
- <ProductOptionsInAddToCartModal
- productOptions={productOptions}
- selected={selected}
- disabledMap={disabledMap}
- onOptionClick={handleOptionClick}
- />
- </div>
- </div>
- </DrawerBody>
- <DrawerFooter className="flex-col border-t border-gray-200 gap-y-4 p-4">
- <div className="flex justify-between items-center">
- <Price
- className="text-ly-14 text-ly-gray line-through"
- amount={String(currentVariantInfo.totalLinePrice)}
- currencyCode="USD"
- />
- <div className="flex items-center">
- <Price
- className="text-ly-14 bg-ly-gold py-0.5 px-1"
- amount={String(currentVariantInfo.save)}
- currencyCode="USD"
- />
- <Price
- className="text-ly-20 font-bold ml-1.5"
- amount={String(currentVariantInfo.totalNowPrice)}
- currencyCode="USD"
- />
- </div>
- </div>
- <div>
- <FooterBtnInAddToCartModal
- isAvailable={isCurrentSelectionAvailable}
- isLoading={isCartLoading}
- onAddToCart={()=> addToCartHandler(onClose)}
- onBuyNow={()=> buyNowHandler(onClose)}
- />
- </div>
- </DrawerFooter>
- </>
- )}
- </DrawerContent>
- </Drawer>
- </Portal>
- );
- }
|