fogwind 2 дней назад
Родитель
Сommit
3ac1b949b3

+ 81 - 1
README.md

@@ -220,7 +220,87 @@ COMPANY_NAME=Your Company Name
 2. [JSON-LD](https://www.cnblogs.com/swizard/p/19476232)
 
 
-@todo 购物车详情优化(cart-slice中规定了购物车数据的type,但是接口返回数据明显与规定的数据类型不匹配,还硬往购物车state里存)
+
 ## 关于购物车token
 官方原来的逻辑: 下单成功后,之前的购物车token就会失效,需要重新创建购物车token。
 > 但是下单成功后代码中并没有删除原来的购物车token,也没有获取新的购物车token。此时用户再加购物车,接口会返回来新的购物车token,而代码中也没有把旧购物车token替换成新的购物车token(当cookie里有购物车token但该token失效时,加购接口返回数据中的cartToken字段才是购物车token,否则cartToken字段是购物车id)。
+
+## 关于ApolloClient
+
+如何指定接口返回数据的数据类型: https://www.apollographql.com/docs/react/data/typescript
+
+
+
+### ApolloClient错误处理
+Apollo Client supports the following error policies:
+
+|Policy|	Description|
+|----|----|
+|none	|If the response includes errors, they are returned in the error field and the response data is set to undefined even if the server returns data in its response. This means network errors and GraphQL errors result in a similar response shape. This is the default error policy.|
+|ignore|	Errors are ignored (error is not populated), and any returned data is cached and rendered as if no errors occurred. Note: data may also be undefined in the event a network error occurs.|
+|all|	Both data and error are populated and any returned data is cached, enabling you to render both partial results and error information.|
+>The default value is none, which tells Apollo Client to treat all GraphQL errors as runtime errors. In this case, Apollo Client discards any query response data returned by the server and sets the error property in the useQuery result object.
+
+>If you set errorPolicy to all, useQuery does not discard query response data, allowing you to render partial results.
+
+apollo client 中的错误分类:
+
+|错误类型|	触发场景|	Promise 状态|	捕获方式|	与 errorPolicy 的关系|
+|----|----|----|----|----|
+|GraphQL 错误(字段校验、权限、业务逻辑抛出)|	服务器返回 200,errors 数组有内容|	resolve(不会被 reject)|	检查返回值中的 errors 数组;useQuery 中通过 error 属性|	errorPolicy 决定 data 是部分数据还是 undefined,以及是否暴露 errors|
+|网络错误(断网、DNS 解析失败、CORS、服务器 5xx 等)|	请求根本没到 GraphQL 引擎,或没有合法响应|	reject|	try...catch 包裹异步调用,或 .catch() 方法;useQuery 中通过 error.networkError|	不影响网络错误的处理,此时通常没有 data,errorPolicy 作用有限|
+
+
+## 关于注册和登录流程
+
+next-auth只负责登录和校验用户
+
+
+
+## 关于cookie的携带
+fetch 不会发送跨域 cookie,除非明确指定 credentials 的值允许跨域发送cookie。( credentials 的默认值为 same-origin,表示仅在同源的请求中发送 cookie。)
+
+也就是说本项目中用bagistoFetch调后端接口(php后端)时是不向php后端发送cookie的。
+
+在浏览器中使用ApolloClient,是先调本项目的代理接口,再由代理接口调后端接口,代理接口中还是用的bagistoFetch调后端接口。
+
+在服务端直接使用ApolloClient时,cookie会发送给后端,因为`/src/lib/apollp-client.ts`中明确指定了credentials的值是 'include'。
+> ApolloClient底层也是用的fetch函数发起的请求。
+
+## 关于useEffect
+useEffect 只会在客户端执行,具体是在浏览器绘制后执行,服务端渲染时不会运行(Next.js 在服务器端会跳过所有 useEffect / useLayoutEffect)。
+
+
+## TODO
+需要做的工作:
+1. 注册成功后需要走next-auth的登录流程
+2. 登录成功后需要合并购物车(在服务端完成) -- 待定
+
+3. createCart 接口报错 -- 已解决
+4. 由于apollo client缓存策略以及graphql代理接口问题导致获取不到数据 -- 已解决
+     3和4的问题是相互关联的
+
+5. 尝试升级apollo client版本到v4 -- 完成
+6. jwt-cookie 放弃base64编码,尝试改为加密解密
+
+7. 登录状态点击退出登录后GetCartItem接口会报错 -- 待处理
+  错误信息:
+  debugMessage: "Call to a member function load() on null"
+  file: "/var/www/html/nshop/packages/Webkul/BagistoApi/src/State/CartTokenProcessor.php"
+  line: 913
+
+8. apollo client 升级后需要用新的方法设置返回数据的类型
+9. Apollo client的错误处理机制 
+10. 产品详情页代码优化
+
+## GetCartItem,CreateMergeCart 接口报500 (已解决)
+需要前端处理错误,不能直接报错。
+
+解决方案:
+- GetCartItem 是在bagistoFetch请求方法中对GetCartItem接口的错误进行拦截
+- CreateMergeCart 是在登录后判断游客状态时的购物车,购物车中有数据时才调CreateMergeCart接口
+
+按照官方的说法,当购物车为空时,调GetCartItem接口确实会报500(cart not found),有意为之。理想逻辑是只有用户进行加购操作的时候才会创建cartToken。
+
+现在理一下逻辑: 用户登录时会返回一个token,这个token只是后端校验用户信息的token,并不是cartToken。
+也就是说虽然用户登录了,也创建了cartToken,但是购物车没激活,所以GetCartItem接口会报500。只有当用户进行加购操作的时候才会激活购物车。

+ 95 - 63
src/app/api/graphql/route.ts

@@ -35,82 +35,114 @@ const ALLOWED_OPERATIONS: Record<string, any> = {
     CreateCheckoutPaymentMethod: CREATE_CHECKOUT_PAYMENT_METHODS,
     CreateCheckoutOrder: CREATE_CHECKOUT_ORDER,
     CreateProductReview: CREATE_PRODUCT_REVIEW,
-    GetProductById: GET_PRODUCT_BY_URL_KEY,
 };
+const QUERY_OPERATIONS: Record<string, any> = {
+    GetProductById: GET_PRODUCT_BY_URL_KEY,
+}
+
+interface FetchOption  {
+    query: string;
+    variables?: Record<string, any>;
+    cache?: RequestCache;
+    guestToken?: string;
+    operationName?: string;
+}
+
+// 需要authorization的operation
+function authorizationOperations(body: Record<string, any>,req:NextRequest): FetchOption {
+    const { operationName, query: bodyGraphqlQuery, variables } = body;
+    const guestToken = getAuthToken(req);
+    const query = ALLOWED_OPERATIONS[operationName];
+    let finalVariables = variables;
+
+    if (operationName === 'CheckoutPaymentMethods' || operationName === 'CheckoutShippingRates') {
+        finalVariables = { ...variables };
+    }
+
+    if (operationName === 'CreateCheckoutPaymentMethod') {
+        finalVariables = {
+            ...variables,
+            successUrl: variables?.successUrl ?? `payment/success`,
+            failureUrl: variables?.failureUrl ?? `payment/failure`,
+            cancelUrl: variables?.cancelUrl ?? `payment/cancel`
+        };
+    }
+
+    if (operationName === 'createCheckoutAddress' && body.billingFirstName) {
+        finalVariables = {
+            billingFirstName: body.billingFirstName,
+            billingLastName: body.billingLastName,
+            billingEmail: body.billingEmail,
+            billingAddress: body.billingAddress,
+            billingCity: body.billingCity,
+            billingCountry: body.billingCountry,
+            billingState: body.billingState,
+            billingPostcode: body.billingPostcode,
+            billingPhoneNumber: body.billingPhoneNumber,
+            billingCompanyName: body.billingCompanyName,
+            useForShipping: body.useForShipping,
+            ...(!body.useForShipping && {
+                shippingFirstName: body.shippingFirstName,
+                shippingLastName: body.shippingLastName,
+                shippingEmail: body.billingEmail,
+                shippingAddress: body.shippingAddress,
+                shippingCity: body.shippingCity,
+                shippingCountry: body.shippingCountry,
+                shippingState: body.shippingState,
+                shippingPostcode: body.shippingPostcode,
+                shippingPhoneNumber: body.shippingPhoneNumber,
+                shippingCompanyName: body.shippingCompanyName,
+            })
+        };
+    }
+
+    if (operationName === 'createAddProductInCart' && body.productId) {
+        finalVariables = {
+            cartId: body.cartId ?? null,
+            productId: body.productId,
+            quantity: body.quantity,
+        };
+    }
+    return {
+        query,
+        variables: finalVariables,
+        cache: "no-store",
+        guestToken,
+        operationName
+    }
+}
+
+
+// 不需要authorization的operation
+function notAuthorizationOperations(body: Record<string, any>): FetchOption {
+    const { operationName, query: bodyGraphqlQuery, variables } = body;
+    const query = bodyGraphqlQuery;
+    return {
+        query,
+        variables,
+        cache: "no-store",
+        operationName
+    }
+}
 
 export async function POST(req: NextRequest) {
     try {
         const body = await req.json();
-        const { operationName, query: bodyGraphqlQuery, variables } = body;
-        const guestToken = getAuthToken(req);
+        const { operationName } = body;
 
-        if (!operationName || !ALLOWED_OPERATIONS[operationName]) {
+        if (!operationName) {
             return NextResponse.json(
                 { message: "Invalid or unauthorized operation: " + (operationName || "missing") },
                 { status: 400 }
             );
         }
-        const query = ALLOWED_OPERATIONS[operationName];
-        // const query = bodyGraphqlQuery; // 核心修复
-
-        let finalVariables = variables;
-
-        if (operationName === 'CheckoutPaymentMethods' || operationName === 'CheckoutShippingRates') {
-            finalVariables = { ...variables };
-        }
-
-        if (operationName === 'CreateCheckoutPaymentMethod') {
-            finalVariables = {
-                ...variables,
-                successUrl: variables?.successUrl ?? `payment/success`,
-                failureUrl: variables?.failureUrl ?? `payment/failure`,
-                cancelUrl: variables?.cancelUrl ?? `payment/cancel`
-            };
-        }
-
-        if (operationName === 'createCheckoutAddress' && body.billingFirstName) {
-            finalVariables = {
-                billingFirstName: body.billingFirstName,
-                billingLastName: body.billingLastName,
-                billingEmail: body.billingEmail,
-                billingAddress: body.billingAddress,
-                billingCity: body.billingCity,
-                billingCountry: body.billingCountry,
-                billingState: body.billingState,
-                billingPostcode: body.billingPostcode,
-                billingPhoneNumber: body.billingPhoneNumber,
-                billingCompanyName: body.billingCompanyName,
-                useForShipping: body.useForShipping,
-                ...(!body.useForShipping && {
-                    shippingFirstName: body.shippingFirstName,
-                    shippingLastName: body.shippingLastName,
-                    shippingEmail: body.billingEmail,
-                    shippingAddress: body.shippingAddress,
-                    shippingCity: body.shippingCity,
-                    shippingCountry: body.shippingCountry,
-                    shippingState: body.shippingState,
-                    shippingPostcode: body.shippingPostcode,
-                    shippingPhoneNumber: body.shippingPhoneNumber,
-                    shippingCompanyName: body.shippingCompanyName,
-                })
-            };
+        let fetchOption: FetchOption = notAuthorizationOperations(body);
+        if(ALLOWED_OPERATIONS[operationName]) {
+            fetchOption = authorizationOperations(body,req);
         }
 
-        if (operationName === 'createAddProductInCart' && body.productId) {
-            finalVariables = {
-                cartId: body.cartId ?? null,
-                productId: body.productId,
-                quantity: body.quantity,
-            };
-        }
-
-        const response = await bagistoFetch<any>({
-            query,
-            variables: finalVariables,
-            cache: "no-store",
-            guestToken,
-            operationName
-        });
+        
+        const response = await bagistoFetch<any>(fetchOption);
         // console.log('response ------ ', response);
         return NextResponse.json({
             data: response.body.data,

+ 2 - 0
src/app/layout.tsx

@@ -5,6 +5,7 @@ import { generateMetadataForPage } from "@utils/helper";
 import { staticSeo } from "@utils/metadata";
 import { SpeculationRules } from "@components/theme/SpeculationRules";
 import { ErrorBoundary } from "@/components/error/ErrorBoundary";
+import { AddToCartModalWrapper } from "@components/common/AddToCartModal/AddToCartModalWrapper";
 import clsx from "clsx";
 
 // Locale revision marker — required for SSR a11y locale sync (Next.js i18n).
@@ -52,6 +53,7 @@ export default function RootLayout({
           <ErrorBoundary>
             <GlobalProviders>
               {children}
+              <AddToCartModalWrapper />
             </GlobalProviders>
             <SpeculationRules />
           </ErrorBoundary>

+ 16 - 0
src/components/Portal.tsx

@@ -0,0 +1,16 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { createPortal } from 'react-dom';
+
+export default function Portal({ children }: { children: React.ReactNode }) {
+  const [mounted, setMounted] = useState(false);
+
+  // useEffect 只会在客户端执行,服务端渲染时不会运行(Next.js 在服务器端会跳过所有 useEffect / useLayoutEffect)。
+  useEffect(() => {
+    setMounted(true); // 组件挂载后,把标志位置为 true
+  }, []); // 空依赖数组,仅执行一次
+
+  if (!mounted) return null; // SSR 时不渲染,避免 hydration 错误
+  return createPortal(children, document.body);
+}

+ 3 - 0
src/components/catalog/product/ProductCard.tsx

@@ -4,6 +4,7 @@ import Grid from "@/components/theme/ui/grid/Grid";
 import AddToCartButton from "@/components/theme/ui/AddToCartButton";
 import { NextImage } from "@/components/common/NextImage";
 import { Price } from "@/components/theme/ui/Price";
+import { OpenAddToCartModalButton } from "@/components/common/AddToCartModal/OpenAddToCartModalButton";
 
 type ProductCardProps = {
   currency: string;
@@ -90,6 +91,8 @@ export const ProductCard: FC<ProductCardProps> = ({
               currencyCode={currency}
             />
           )}
+          <OpenAddToCartModalButton productUrlKey={product.urlKey} />
+
         </div>
       </div>
     </Grid.Item>

+ 10 - 4
src/components/catalog/type.ts

@@ -34,11 +34,17 @@ export interface ProductFlexibleVariantPriceIndices{
   regular_min_price:string;
 }
 
+export interface VatiantImages {
+  id: number;
+  position: number;
+  url: string;
+}
+
 export interface ProductFlexibleVariant {
   id: string;
   _id: number;
   sku: string;
-  variantImages: string | null | Array<{id: number;position: number;url: string;}>; 
+  variantImages: string | null | VatiantImages[]; 
   optionValues: string | Array<ProductFlexibleVariantValue>
   quantity:number;
   price: string;
@@ -49,14 +55,14 @@ export interface ProductMediaType{
   id: string;
   path: string;
   publicPath: string;
-  positionpublicPath: string; 
+  position: string;
 }
 
 // 定义解析后的类型(根据最初类型裁剪)
 export type ResolvedVariant = Omit<ProductFlexibleVariant, 'optionValues' | 'variantImages | priceIndices'> & {
   optionValues: ProductFlexibleVariantValue[];
-  variantImages: Exclude<ProductFlexibleVariant['variantImages'], string>; // 去掉 string 可能性
-  priceIndices: Exclude<ProductFlexibleVariant['priceIndices'], string>;
+  variantImages: VatiantImages[]; // 去掉 string 可能性
+  priceIndices: ProductFlexibleVariantPriceIndices[];
 };
 
 export interface ProductSectionNode {

+ 312 - 0
src/components/common/AddToCartModal/AddToCartModal.tsx

@@ -0,0 +1,312 @@
+"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>
+    );
+}

+ 12 - 0
src/components/common/AddToCartModal/AddToCartModalWrapper.tsx

@@ -0,0 +1,12 @@
+"use client";
+
+import { AddToCartModal } from "@components/common/AddToCartModal/AddToCartModal";
+import { useAppSelector } from "@/store/hooks";
+
+export function AddToCartModalWrapper() {
+  const {product} = useAppSelector((state) => state.addToCartDialog);
+
+  return (
+    <AddToCartModal key={product?.id} />
+  );
+}

+ 40 - 0
src/components/common/AddToCartModal/FooterBtnInAddToCartModal.tsx

@@ -0,0 +1,40 @@
+"use client";
+
+import clsx from "clsx";
+import { LoadingSpinner } from "@components/common/LoadingSpinner";
+export default function FooterBtnInAddToCartModal({
+    isAvailable,
+    isLoading,
+    onBuyNow,
+    onAddToCart
+}: {
+    isAvailable: boolean; 
+    isLoading: boolean;
+    onBuyNow: () => void,
+    onAddToCart: () => void
+}) {
+
+    const btnClass = 'flex-initial flex items-center justify-center w-40 h-ly-46 rounded-4xl text-ly-16 font-bold text-white';
+    return (
+        <div className="flex justify-between">
+            {isAvailable ? 
+                (<>
+                    <button className={clsx(btnClass,'bg-ly-deepgreen')}
+                        onClick={onBuyNow}
+                    >
+                        Buy Now {isLoading && <LoadingSpinner className="ml-2" />}
+                        
+                    </button>
+                    <button className={clsx(btnClass, 'bg-ly-middlegreen')}
+                        onClick={onAddToCart}
+                    >
+                        Add to Cart {isLoading && <LoadingSpinner className="ml-2" />}
+                    </button>
+                </>)
+            :
+                <p className="text-center text-ly-14">The selected options are not available!</p>
+            }
+            
+        </div>
+    );
+}

+ 27 - 0
src/components/common/AddToCartModal/ImgSwiperInAddToCartModal.tsx

@@ -0,0 +1,27 @@
+"use client";
+import { memo } from "react";
+import { Swiper, SwiperSlide } from 'swiper/react';
+import 'swiper/css';
+import Image from "next/image";
+import { ProductMediaType } from "@/components/catalog/type";
+
+const ImgSwiperInAddToCartModal = memo(function ImgSwiperInAddToCartModal({images}: {images: ProductMediaType[];}) { 
+    return (
+        <Swiper
+            spaceBetween={10}
+            slidesPerView={3}
+            onSlideChange={() => console.log('slide change')}
+            onSwiper={(swiper) => console.log(swiper)}
+        >
+            {images.map((img) => {
+                return (
+                <SwiperSlide key={img.id}>
+                    <Image src={img.publicPath} alt={"todo test "} width={175} height={233} />
+                </SwiperSlide>
+                );
+            })}
+        </Swiper>
+    );
+});
+
+export default ImgSwiperInAddToCartModal;

+ 61 - 0
src/components/common/AddToCartModal/OpenAddToCartModalButton.tsx

@@ -0,0 +1,61 @@
+"use client";
+
+import { useAppDispatch } from "@/store/hooks";
+import { openAddToCartDialog } from '@/store/slices/addToCartDialogSlice';
+import { GET_PRODUCT_BY_URL_KEY } from "@/graphql";
+import { useLazyQuery } from '@apollo/client/react';
+import { SingleProductResponse } from "@/components/catalog/type";
+import { LoadingSpinner } from "@components/common/LoadingSpinner";
+import ShoppingCartIcon from "@components/common/icons/ShoppingCartIcon";
+import { useCustomToast } from "@utils/hooks/useToast";
+
+export function OpenAddToCartModalButton({productUrlKey}: {productUrlKey: string}) {
+    const dispatch = useAppDispatch();
+    // const [loading, setLoading] = useState(false);
+    const { showToast } = useCustomToast();
+
+    const [getContent, { loading }] = useLazyQuery<SingleProductResponse>(GET_PRODUCT_BY_URL_KEY,{
+        fetchPolicy: 'network-only'
+    });
+    const openDialog = async () => {
+        if( loading) return; 
+        try {
+            let result = await getContent({
+                variables: { urlKey: productUrlKey }
+            });
+
+            console.log('product ----- ', result);// 返回的数据中有__typename,如何过滤 @todo
+
+            let productData = result?.data?.product;
+            if(result.error) {
+                showToast(result.error.message, "danger");
+            } else {
+                if(productData) {
+                    dispatch(openAddToCartDialog(productData));
+                } else {
+                    //提示产品数据为空
+                    showToast('Product data is null', "danger");
+                }
+            }
+            
+        } catch (error) {
+            console.log('error ----- ', error);
+            let errorMessage = "Network Error";
+            if (error instanceof Error) {
+                errorMessage = error.message;
+            } else if (typeof error === 'object' && error !== null && 'message' in error) {
+                errorMessage = String((error as any).message);
+            }
+            
+            showToast(errorMessage, "danger");
+        }
+       
+    }
+    return (
+
+        <button onClick={openDialog} disabled={loading}>
+            { loading ? <LoadingSpinner colorClassName={"text-ly-green"} /> : <ShoppingCartIcon />}
+        </button>
+        
+    );
+};

+ 63 - 0
src/components/common/AddToCartModal/ProductOptionsInAddToCartModal.tsx

@@ -0,0 +1,63 @@
+"use client";
+
+import clsx from "clsx";
+import { 
+    ProductOption, 
+} from "@/components/catalog/type";
+
+export default function ProductOptionsInAddToCartModal({
+    productOptions,
+    selected,
+    disabledMap,
+    onOptionClick,
+}: {
+    productOptions: ProductOption[];
+    selected: Record<number, number>;
+    disabledMap: Record<number, boolean>;
+    onOptionClick: (result:{
+        clickOptionId: number; 
+        clickValueId: number;
+    }) => void;
+}) {
+
+    let classNameOptionValueBtn = "text-ly-14 leading-ly-24 border border-solid rounded-lg pt-1.5 pb-1.5 pl-3 pr-3 bg-white";
+    // 处理选项点击
+    const handleOptionClick = (optionId: number, valueId: number) => {
+        onOptionClick({
+            clickOptionId: optionId, 
+            clickValueId: valueId,
+        });
+    };
+
+    return (
+        <>
+            this is test
+            {productOptions.map((option) => { 
+                return (<div key={option.id}>
+                    <div className="text-ly-12 font-medium">{option.label}</div>
+                    <div className="flex flex-wrap gap-3 mt-3">
+                        {option.values.map((value) => { 
+                            const isSelected = selected[option.id] === value.id;
+                            const isDisabled = disabledMap[value.id] ? true : false;
+                            return (
+                                <button key={value.id}
+                                    type="button"
+                                    onClick={() => !isDisabled && handleOptionClick(option.id, value.id)}
+                                    className={clsx(classNameOptionValueBtn, {
+                                        'border-ly-green text-ly-green': isSelected,
+                                        'border-black text-black': !isSelected,
+                                        'opacity-40 cursor-not-allowed strokeline-bg-disabled': isDisabled
+                                    })}
+                                    disabled={isDisabled}
+                                >
+                                    {value.label}
+                                </button>
+                            );
+
+                        })}
+                    </div>
+                </div>)
+            })}
+        </>
+    );
+}

+ 34 - 0
src/store/slices/addToCartDialogSlice.ts

@@ -0,0 +1,34 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { SingleProductResponse, ProductNode } from "@/components/catalog/type";
+
+interface DialogState {
+  isOpen: boolean;
+  product: ProductNode | null;
+}
+
+const initialState: DialogState = {
+  isOpen: false,
+  product: null,
+};
+
+const addToCartDialogSlice = createSlice({
+  name: 'addToCartDialog',
+  initialState,
+  reducers: {
+    openAddToCartDialog(state, action: PayloadAction<ProductNode>) {
+      state.isOpen = true;
+      state.product = action.payload;
+    },
+    closeAddToCartDialog(state) {
+      state.isOpen = false;
+      // 可以保留 product 用于退出动画(建议动画完成后再清空,这里简单处理)
+      // 如果需要等动画结束,可以在组件中延迟 dispatch(clearProduct())
+    },
+    clearAddToCartProduct(state) {
+      state.product = null;
+    },
+  },
+});
+
+export const { openAddToCartDialog, closeAddToCartDialog, clearAddToCartProduct } = addToCartDialogSlice.actions;
+export default addToCartDialogSlice.reducer;

+ 2 - 0
src/store/store.ts

@@ -2,11 +2,13 @@ import { configureStore } from "@reduxjs/toolkit";
 
 import cartSlice from "./slices/cart-slice";
 import userSlice from "./slices/user-slice";
+import addToCartDialogReducer from "./slices/addToCartDialogSlice";
 
 export const store = configureStore({
   reducer: {
     cartDetail: cartSlice,
     user: userSlice,
+    addToCartDialog: addToCartDialogReducer,
   },
 });
 

+ 1 - 0
src/utils/hooks/useAddToCart.ts

@@ -28,6 +28,7 @@ export const useAddProduct = () => {
     CREATE_ADD_PRODUCT_IN_CART,
     {
       onCompleted: (res) => {
+        console.log('useAddToCart onCompleted run ----- 0');
         const responseData = res?.createAddProductInCart?.addProductInCart;
 
         if (!responseData?.success) {

+ 192 - 0
src/utils/variantTools.ts

@@ -0,0 +1,192 @@
+import { 
+    ProductOption, 
+    VatiantImages,
+    ResolvedVariant,
+    ProductFlexibleVariant,
+    ProductFlexibleVariantValue,
+    ProductFlexibleVariantPriceIndices
+} from "@/components/catalog/type";
+
+
+/**
+ * 辅助函数:判断一个完整的选项值ID组合是否对应一个可购买的变体(quantity > 0)
+ * @param selectedIds {number[]} 选中的选项值ID的数组
+ * @param variants {ResolvedVariant[]} 变体列表
+ * @returns {boolean} 该选项组和对应的变体是否可用
+ *  */ 
+export function isCombinationAvailable(
+    selectedIds: number[],
+    variants: ResolvedVariant[]
+): boolean {
+    // some 每一项都带入参数函数执行,如果遇到一项带入函数执行结果是true,则返回true;如果所有项带入函数执行,结果都是false,则返回false
+    return variants.some((variant) => {
+        if (variant.quantity <= 0) return false;
+        const values = variant.optionValues;
+
+        const variantValueIds = values.map((v) => v.id);
+        return (
+            selectedIds.length === variantValueIds.length &&
+            selectedIds.every((id) => variantValueIds.includes(id))
+        );
+    });
+}
+
+/** 
+ * 辅助函数:根据部分选中的映射,判断某个选项值是否可用
+ * @param optionId {number} 选项组ID
+ * @param valueId {number} 选项值ID
+ * @param currentSelected {Record<number, number>} 当前已选中的映射
+ * @param allOptions 产品选项列表
+ * @param variants 产品变体列表
+ * @returns {boolean} 该选项值是否可用
+ * */ 
+export function isOptionValueAvailable(
+    optionId: number,
+    valueId: number,
+    currentSelected: Record<number, number>, // 当前已选中的映射
+    allOptions: ProductOption[],
+    variants: ResolvedVariant[]
+): boolean {
+    const testSelected = { ...currentSelected, [optionId]: valueId };
+    // 检查是否所有选项组都已选中(有些组可能尚未选择,但禁用判断时我们只关心已选中的组能否与当前测试值组成可用变体)
+    // 获取所有选项值ID的列表
+    const selectedValueIds = Object.values(testSelected);
+    // 如果有任何组未选中,则无法构成完整变体,此时应视为可能可用(不禁用),因为用户还需要继续选择
+    if (selectedValueIds.length < allOptions.length) {
+        // 但需要检查:在已选中的这些组中,是否存在一个变体其对应的值包含这些已选中的值?
+        // 即是否存在变体,其选项值集合包含当前测试选中的值(作为子集)
+        return variants.some((variant) => {
+            if (variant.quantity <= 0) return false;
+            const values = variant.optionValues;
+            const variantValueIds = values.map((v) => v.id);
+            // 检查测试选中的值是否都是该变体选项值的子集
+            return selectedValueIds.every((id) => variantValueIds.includes(id));
+        });
+    } else {
+        // 所有组都已选中,直接检查完整组合是否可用
+        return isCombinationAvailable(selectedValueIds, variants);
+    }
+}
+/**
+ * 根据传入的value id判断变体中是否存在可购买变体,如果有返回true,如果没有则返回false
+ * @param valueId {number} 选项值ID
+ * @param variants {ResolvedVariant[]} 变体列表
+ * @returns {boolean} 该value id是否可用
+ * */
+export function isValueAvailable( valueId: number, variants: ResolvedVariant[]): boolean {
+    return variants.some((variant) => {
+        if (variant.quantity <= 0) return false;
+        const values = variant.optionValues;
+        const variantValueIds = values.map((v) => v.id);
+        return variantValueIds.includes(valueId);
+    });
+}
+/** 
+ * 根据传入的value id组合,找到一个最近的可购买的变体
+ * @param fixedOptionId {number} 调过校验的选项组id
+ * @param fixedValueId {number} 调过校验的选项值id
+ * @param valueIdMap {Record<number, number>} 选中的选项值映射
+ * @param allOptions 产品的选项列表
+ * @param variants 产品的变体列表
+ * @returns {Record<number, number>} 返回一个最近可用变体的选项值映射
+ * */
+export function findNearestAvailableVariant(
+    fixedOptionId: number,
+    fixedValueId: number,
+    valueIdMap: Record<number, number>, 
+    allOptions: ProductOption[],
+    variants: ResolvedVariant[] // 可购买的变体列表
+): Record<number, number>{
+    let res:Record<number, number> = {[fixedOptionId]: fixedValueId};
+
+    for (const option of allOptions) {
+        let optionId = option.id;
+        if(fixedOptionId === optionId) continue;
+        let valueId = valueIdMap[optionId];
+        if(isOptionValueAvailable(optionId,valueId,res,allOptions,variants)) {
+            res = {...res,[optionId]: valueId};
+        } else {
+            let found = false;
+            for(let i = 0; i < option.values.length; i++) {
+                if(isOptionValueAvailable(optionId,option.values[i].id,res,allOptions,variants)) {
+                    found = true;
+                    res = {...res,[optionId]: option.values[i].id};
+                    break;
+                }
+            }
+            if (!found) {
+                // 极端情况:找不到可用值,保留原值以维持完整状态
+                res = { ...res, [optionId]: valueId };
+            }
+        }
+    }
+
+    return res;
+}
+
+
+// 递归回溯查找第一个可用组合
+export function getFirstAvailable(productOptions: ProductOption[],flexibleVariants: ResolvedVariant[]) {
+    function findFirstAvailable( 
+        optionIndex: number, 
+        currentMap: Record<number, number> 
+    ): Record<number, number> | null {
+        if (optionIndex >= productOptions.length) {
+            // 所有组都已选,检查完整组合是否可用
+            const valueIds = Object.values(currentMap);
+            return isCombinationAvailable(valueIds, flexibleVariants) ? { ...currentMap } : null;
+        }
+
+        const option = productOptions[optionIndex];
+        for (const value of option.values) {
+            const nextMap = { ...currentMap, [option.id]: value.id };
+            // 剪枝:检查当前部分组合是否可能导向可用变体
+            const partialValueIds = Object.values(nextMap);
+            const hasPotential = flexibleVariants.some((v) => {
+                if (v.quantity <= 0) return false;
+                const vIds = v.optionValues.map((ov) => ov.id);
+                return partialValueIds.every((id) => vIds.includes(id));
+            });
+            if (!hasPotential) continue;
+
+            const result = findFirstAvailable(optionIndex + 1, nextMap);
+            if (result) return result;
+        }
+        return null;
+    }
+
+    return findFirstAvailable(0, {});
+}
+
+// 格式化接口返的变体数据
+export function formatFlexibleVariants(originData: ProductFlexibleVariant[] | undefined) {
+    let res: ResolvedVariant[] = [];
+    if(originData) {
+     res = originData.map((edge: ProductFlexibleVariant ) => {
+        let variantImages: VatiantImages[] = [], 
+            optionValues: ProductFlexibleVariantValue[] = [], 
+            priceIndices: ProductFlexibleVariantPriceIndices[] = [];
+        if(typeof edge.variantImages === 'string') {
+            variantImages = JSON.parse(edge.variantImages);
+        } else if(edge.variantImages === null) {
+            variantImages = [];
+        }
+        if(typeof edge.optionValues === 'string') {
+            optionValues = JSON.parse(edge.optionValues);
+        }
+        if(typeof edge.priceIndices === 'string') {
+            priceIndices = JSON.parse(edge.priceIndices);
+        } else if(edge.priceIndices === null) {
+            priceIndices = [];
+        }
+        return {...edge,optionValues,variantImages,priceIndices};
+      });
+    }
+
+    return res;
+}
+
+// 获取可以购买的变体
+export function getAvailableVariants(flexibleVariants: ResolvedVariant[]) {
+    return flexibleVariants.filter((variant) => variant.quantity > 0);
+}